refactor: reach the master (#93)

* refactor(db): use new sql.nvim api

* refactor(*): create const, algo and add more util

* refactor(picker): try to cleanup and make it easy to read

FIXME: it seems that I didn't refactor the entry maker right.

* refactor(*): move util to lua/frecency

* misc(db): flag possible bug

* refactor(db): reflect changes introduced in https://github.com/tami5/sql.nvim/pull/96

* refactor(db): move set_config to frecency.lua

* new(db): ignore term and octo paths

This may possibly fix the issue with entry.count being nil.

* misc(picker): move health out of export

* refactor(util)

* refactor(db): general

* misc(*): nothing major

* refactor(db): abbreviate table namespace access

* refactor(picker): working-ish

* fix(*): general

* refactor(picker): move fd function to the end of the file

* refactor(*): remove the need for db.init

* new(db): use foreign keys

* sync with sqlite.lua@#105

* feat: add settings for StyLua

* fix: detect the valid module in healthcheck

See #65

* style: fix by StyLua

* fix: detect CWD tag to cut paths

See #66

* fix: show icon before directory

See #67

* fix: deal with show_filter_column option validly

* feat: support opts.workspace (#68)

* doc(readme): update config example (#33)

remove comma causing error.

* doc(readme): fix packer install instructions (#34)

Co-authored-by: tami5 <65782666+tami5@users.noreply.github.com>

* doc: follow sqlite new release (#40)

* refactor(sql_wrapper): follow sqlite new release

* update readme

* refactor: follow telescope's interface changes

See #46

* feat: add default_workspace tag

See #43

* fix: fetch workspaces in addition to completing

See #72

* Update url for sqlite dependency (#64)

The old repository on github redirects to this one.

* fix: use vim.notify not to block outputs

See #75

* feat: opts.path_display to customize

See #76

* Enable to specify tags to show the tails (#77)

* Enable to specify tags to show the tails

* Add doc for show_filter_column

* feat: use more reasonable matcher to sort

See #73

* Fix broken Frecency Algorithm link in README.md (#82)

MDN appear to have removed the Frecency Algorithm page linked in the README document. Updating link to use archived version.

* fix: set the telescope default filetype in prompt

See #81

* feat: use the newer API to define autocmds

See #79

* refactor: use new API and add check for devicons

* feat: detect entries when it has added new ones

See #87

* fix: use valid widths to show entries

* fix: use substr matcher to use sorting by scores

See #94

* Revert "fix: set the telescope default filetype in prompt"

This reverts commit 4937f7045438412e31a77374f91230bdbcbeb34d.

* fix: enable to filter by valid paths

* refactor: do nothing until calling setup()

See #80

* fix: fix typo for `workspaces`

* fix: show `0` score for unindexed instead of `nil`

* style: fix by StyLua

* fix: return a valid entry with get()

It have used where() to get the entry and where() uses get() to fetch
from DB. But this table has been customized by overwriting get(), so it
has a bug to return the same entry every time it calls. This fixes it.

* refactor: get the buffer name explicitly

* fix: clean up logic to validate DB

* feat: enable to work db_root option

* fix: show no msg when no need to validate in auto

---------

Co-authored-by: Tami <65782666+tami5@users.noreply.github.com>
Co-authored-by: Munif Tanjim <hello@muniftanjim.dev>
Co-authored-by: premell <65544203+premell@users.noreply.github.com>
Co-authored-by: Anshuman Medhi <amedhi@connect.ust.hk>
Co-authored-by: Lucas Hoffmann <lucc@users.noreply.github.com>
Co-authored-by: Rohan Orton <rohan.orton@gmail.com>
This commit is contained in:
JINNOUCHI Yasushi 2023-06-10 14:37:08 +09:00 committed by GitHub
parent 0a4a521471
commit 3f709af755
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 690 additions and 808 deletions

6
.stylua.toml Normal file
View File

@ -0,0 +1,6 @@
column_width = 120
line_endings = "Unix"
indent_type = "Spaces"
indent_width = 2
quote_style = "AutoPreferDouble"
no_call_parentheses = true

21
lua/frecency/algo.lua Normal file
View File

@ -0,0 +1,21 @@
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

18
lua/frecency/const.lua Normal file
View File

@ -0,0 +1,18 @@
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://*",
},
}

192
lua/frecency/db.lua Normal file
View File

@ -0,0 +1,192 @@
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

336
lua/frecency/picker.lua Normal file
View File

@ -0,0 +1,336 @@
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 actions = require "telescope.actions"
local conf = require("telescope.config").values
local entry_display = require "telescope.pickers.entry_display"
local finders = require "telescope.finders"
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
---@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,
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 })
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
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
table.insert(i, { filter_path, "Directory" })
table.insert(i, {
formatter(e.name),
util.buf_is_loaded(e.name) and "TelescopeBufferLoaded" or "",
})
return i
end)())
end,
}
end
---Find files
---@param opts table: telescope picker opts
m.fd = function(opts)
opts = opts or {}
if not opts.path_display then
opts.path_display = function(path_opts, filename)
local original_filename = filename
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
filename = "./" .. filename
end
end
return filename
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()
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 }
end
return o
end
picker_opts.attach_mappings = function(prompt_bufnr)
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 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)
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)
end
-- 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")
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

96
lua/frecency/util.lua Normal file
View File

@ -0,0 +1,96 @@
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 scan_opts = { respect_gitignore = true, depth = 100, hidden = true }
-- 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

@ -1,353 +1,29 @@
local has_telescope, telescope = pcall(require, "telescope")
-- TODO: make sure scandir unindexed have opts.ignore_patterns applied
-- TODO: make filters handle mulitple directories
if not has_telescope then
error "This plugin requires telescope.nvim (https://github.com/nvim-telescope/telescope.nvim)"
end
local config = {}
local state = {
results = {},
active_filter = nil,
active_filter_tag = nil,
last_filter = nil,
previous_buffer = nil,
cwd = nil,
show_scores = false,
default_workspace = nil,
user_workspaces = {},
lsp_workspaces = {},
picker = {},
}
-- returns `true` if workspaces exist
---@param bufnr number
---@param force? boolean
---@return boolean workspaces_exist
local function fetch_lsp_workspaces(bufnr, force)
if not vim.tbl_isempty(state.lsp_workspaces) and not force then
return true
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 lsp_workspaces = vim.api.nvim_buf_call(bufnr, vim.lsp.buf.list_workspace_folders)
if not vim.tbl_isempty(lsp_workspaces) then
state.lsp_workspaces = lsp_workspaces
return true
end
state.lsp_workspaces = {}
return false
end
local function get_workspace_tags()
-- Add user config workspaces. TODO: validate that workspaces are existing directories
local tags = {}
for k, _ in pairs(state.user_workspaces) do
table.insert(tags, k)
end
-- Add CWD filter
table.insert(tags, "CWD")
-- Add LSP workpace(s)
if fetch_lsp_workspaces(state.previous_buffer, true) then
table.insert(tags, "LSP")
end
-- print(vim.inspect(tags))
-- TODO: sort tags - by collective frecency?
return tags
end
local function complete(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 tags = get_workspace_tags()
local matches = vim.tbl_filter(function(v)
return vim.startswith(v, base)
end, tags)
return #matches > 0 and matches or ""
end
end
local frecency = function(opts)
local has_devicons, devicons = pcall(require, "nvim-web-devicons")
local entry_display = require "telescope.pickers.entry_display"
local finders = require "telescope.finders"
local Path = require "plenary.path"
local pickers = require "telescope.pickers"
local utils = require "telescope.utils"
local db_client = require "telescope._extensions.frecency.db_client"
-- start the database client
db_client.init(
config.db_root,
config.ignore_patterns,
vim.F.if_nil(config.db_safe_mode, true),
vim.F.if_nil(config.auto_validate, true)
)
opts = opts or {}
state.previous_buffer = vim.fn.bufnr "%"
state.cwd = vim.fn.expand(opts.cwd or vim.fn.getcwd())
fetch_lsp_workspaces(state.previous_buffer)
local os_home = vim.loop.os_homedir()
local function should_show_tail()
local filters = type(state.show_filter_column) == "table" and state.show_filter_column or { "LSP", "CWD" }
return vim.tbl_contains(filters, state.active_filter_tag)
end
local function get_display_cols()
local directory_col_width = 0
if state.active_filter then
if should_show_tail() then
-- TODO: Only add +1 if opts.show_filter_thing is true, +1 is for the trailing slash
directory_col_width = #(utils.path_tail(state.active_filter)) + 1
else
directory_col_width = #(Path:new(state.active_filter):make_relative(os_home)) + 1
end
end
local res = {}
res[1] = state.show_scores and { width = 8 } or nil
if has_devicons and not state.disable_devicons then
table.insert(res, { width = 2 }) -- icon column
end
if state.show_filter_column then
table.insert(res, { width = directory_col_width })
end
table.insert(res, { remaining = true })
return res
end
local os_path_sep = utils.get_separator()
local displayer = entry_display.create {
separator = "",
hl_chars = { [os_path_sep] = "TelescopePathSeparator" },
items = get_display_cols(),
}
if not opts.path_display then
opts.path_display = function (path_opts, filename)
local original_filename = filename
filename = Path:new(filename):make_relative(path_opts.cwd)
if not state.active_filter then
if vim.startswith(filename, os_home) then
filename = "~/" .. Path:new(filename):make_relative(os_home)
elseif filename ~= original_filename then
filename = "./" .. filename
end
end
return filename
end
end
local function filepath_formatter(path_opts0)
local path_opts = {}
for k, v in pairs(path_opts0) do
path_opts[k] = v
end
return function (filename)
path_opts.cwd = state.active_filter or state.cwd
return utils.transform_path(path_opts, filename)
end
end
local formatter = filepath_formatter(opts)
local bufnr, buf_is_loaded, display_filename, hl_filename, display_items, icon, icon_highlight
local make_display = function(entry)
bufnr = vim.fn.bufnr
buf_is_loaded = vim.api.nvim_buf_is_loaded
display_filename = entry.name
hl_filename = buf_is_loaded(bufnr(display_filename)) and "TelescopeBufferLoaded" or ""
display_filename = formatter(display_filename)
display_items = state.show_scores and { { entry.score, "TelescopeFrecencyScores" } } or {}
if has_devicons and not state.disable_devicons then
icon, icon_highlight = devicons.get_icon(entry.name, string.match(entry.name, "%a+$"), { default = true })
table.insert(display_items, { icon, icon_highlight })
end
-- TODO: store the column lengths here, rather than recalculating in get_display_cols()
if state.show_filter_column then
local filter_path = ""
if state.active_filter then
if should_show_tail() then
filter_path = utils.path_tail(state.active_filter) .. os_path_sep
else
filter_path = Path:new(state.active_filter):make_relative(os_home) .. os_path_sep
end
end
table.insert(display_items, { filter_path, "Directory" })
end
table.insert(display_items, { display_filename, hl_filename })
return displayer(display_items)
end
local update_results = function(filter)
local filter_updated = false
-- validate tag
local ws_dir = filter and state.user_workspaces[filter]
if filter == "LSP" and not vim.tbl_isempty(state.lsp_workspaces) then
ws_dir = state.lsp_workspaces[1]
end
if filter == "CWD" then
ws_dir = state.cwd
end
if ws_dir ~= state.active_filter then
filter_updated = true
state.active_filter = ws_dir
state.active_filter_tag = filter
end
if vim.tbl_isempty(state.results) or db_client.has_updated_results() or filter_updated then
state.results = db_client.get_file_scores(state.show_unindexed, ws_dir)
end
return filter_updated
end
-- populate initial results
update_results()
local entry_maker = function(entry)
return {
filename = entry.filename,
display = make_display,
ordinal = entry.filename,
name = entry.filename,
score = entry.score,
}
end
local delim = opts.filter_delimiter or ":"
local filter_re = "^%s*(" .. delim .. "(%S+)" .. delim .. ")"
state.picker = pickers.new(opts, {
prompt_title = "Frecency",
on_input_filter_cb = function(query_text)
-- check for :filter: in query text
local matched, new_filter = query_text:match(filter_re)
if matched then
query_text = query_text:sub(matched:len() + 1)
end
new_filter = new_filter or opts.workspace or state.default_workspace
local new_finder
local results_updated = update_results(new_filter)
if results_updated then
displayer = entry_display.create {
separator = "",
hl_chars = { [os_path_sep] = "TelescopePathSeparator" },
items = get_display_cols(),
}
state.last_filter = new_filter
new_finder = finders.new_table {
results = state.results,
entry_maker = entry_maker,
}
-- print(vim.inspect(new_finder))
end
return { prompt = query_text, updated_finder = new_finder }
end,
attach_mappings = function(prompt_bufnr)
require "telescope.actions".select_default:replace_if(function()
local compinfo = vim.fn.complete_info()
return compinfo.pum_visible == 1
end, function()
local compinfo = vim.fn.complete_info()
local keys = compinfo.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,
finder = finders.new_table {
results = state.results,
entry_maker = entry_maker,
},
previewer = require("telescope.config").values.file_previewer(opts),
sorter = require'telescope.sorters'.get_substr_matcher(opts),
})
state.picker:find()
local buffer = state.picker.prompt_bufnr
vim.api.nvim_buf_set_option(buffer, "filetype", "frecency")
vim.api.nvim_buf_set_option(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
local function set_config_state(opt_name, value, default)
state[opt_name] = value == nil and default or value
end
local function checkhealth()
local has_sql, _ = pcall(require, "sqlite")
if has_sql then
vim.health.report_ok "sql.nvim installed."
-- return "MOOP"
else
vim.health.report_error "Need sql.nvim to be installed."
end
if pcall(require, "nvim-web-devicons") then
vim.health.report_ok "nvim-web-devicons installed."
else
vim.health.report_info "nvim-web-devicons is not installed."
end
end
local picker = require "frecency.picker"
return telescope.register_extension {
setup = function(ext_config)
set_config_state("db_root", ext_config.db_root, nil)
set_config_state("show_scores", ext_config.show_scores, false)
set_config_state("show_unindexed", ext_config.show_unindexed, true)
set_config_state("show_filter_column", ext_config.show_filter_column, true)
set_config_state("user_workspaces", ext_config.workspaces, {})
set_config_state("disable_devicons", ext_config.disable_devicons, false)
set_config_state("default_workspace", ext_config.default_workspace, nil)
config = vim.deepcopy(ext_config)
vim.api.nvim_create_user_command("FrecencyValidate", function (cmd_info)
local safe_mode = not cmd_info.bang
require"telescope._extensions.frecency.db_client".validate(safe_mode)
end, { bang = true, desc = "Clean up DB for telescope-frecency" })
setup = picker.setup,
health = function()
if ({ pcall(require, "sqlite") })[1] then
vim.health.report_ok "sql.nvim installed."
else
vim.health.report_error "sql.nvim 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."
else
vim.health.report_info "nvim-web-devicons is not installed."
end
end,
exports = {
frecency = frecency,
complete = complete,
frecency = picker.fd,
complete = picker.complete,
},
health = checkhealth,
}

View File

@ -1,239 +0,0 @@
local sqlwrap = require("telescope._extensions.frecency.sql_wrapper")
local scandir = require("plenary.scandir").scan_dir
local util = require("telescope._extensions.frecency.util")
local MAX_TIMESTAMPS = 10
local DB_REMOVE_SAFETY_THRESHOLD = 10
-- modifier used as a weight in the recency_score calculation:
local 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
}
local default_ignore_patterns = {
"*.git/*", "*/tmp/*"
}
local sql_wrapper = nil
local ignore_patterns = {}
local function import_oldfiles()
local oldfiles = vim.api.nvim_get_vvar("oldfiles")
for _, filepath in pairs(oldfiles) do
sql_wrapper:update(filepath)
end
vim.notify(("Telescope-Frecency: Imported %d entries from oldfiles."):format(#oldfiles))
end
local function file_is_ignored(filepath)
local is_ignored = false
for _, pattern in pairs(ignore_patterns) do
if util.filename_match(filepath, pattern) then
is_ignored = true
goto continue
end
end
::continue::
return is_ignored
end
local function validate_db(safe_mode)
if not sql_wrapper then return {} end
local queries = sql_wrapper.queries
local files = sql_wrapper:do_transaction(queries.file_get_entries, {})
local pending_remove = {}
for _, entry in pairs(files) do
if not util.fs_stat(entry.path).exists -- file no longer exists
or file_is_ignored(entry.path) then -- cleanup entries that match the _current_ ignore list
table.insert(pending_remove, entry)
end
end
local confirmed = false
if not safe_mode then
confirmed = true
elseif #pending_remove > DB_REMOVE_SAFETY_THRESHOLD then
-- don't allow removal of >N values from DB without confirmation
local user_response = vim.fn.confirm("Telescope-Frecency: remove " .. #pending_remove .. " entries from SQLite3 database?", "&Yes\n&No", 2)
if user_response == 1 then
confirmed = true
else
vim.defer_fn(function() vim.notify("TelescopeFrecency: validation aborted.") end, 50)
end
else
confirmed = true
end
if #pending_remove > 0 then
if confirmed == true then
for _, entry in pairs(pending_remove) do
-- remove entries from file and timestamp tables
sql_wrapper:do_transaction(queries.file_delete_entry , {where = {id = entry.id }})
sql_wrapper:do_transaction(queries.timestamp_delete_entry, {where = {file_id = entry.id}})
end
vim.notify(('Telescope-Frecency: removed %d missing entries.'):format(#pending_remove))
else
vim.notify("Telescope-Frecency: validation aborted.")
end
end
end
-- TODO: make init params a keyed table
local function init(db_root, config_ignore_patterns, safe_mode, auto_validate)
if sql_wrapper then return end
sql_wrapper = sqlwrap:new()
local first_run = sql_wrapper:bootstrap(db_root)
ignore_patterns = config_ignore_patterns or default_ignore_patterns
if auto_validate then
validate_db(safe_mode)
end
if first_run then
-- TODO: this needs to be scheduled for after shada load
vim.defer_fn(import_oldfiles, 100)
end
-- setup autocommands
local group = vim.api.nvim_create_augroup("TelescopeFrecency", {})
vim.api.nvim_create_autocmd({ "BufWinEnter", "BufWritePost" }, {
group = group,
callback = function(args)
local db_client = require "telescope._extensions.frecency.db_client"
db_client.autocmd_handler(args.match)
end,
})
end
local function calculate_file_score(frequency, timestamps)
local recency_score = 0
for _, ts in pairs(timestamps) do
for _, rank in ipairs(recency_modifier) do
if ts.age <= rank.age then
recency_score = recency_score + rank.value
goto continue
end
end
::continue::
end
return frequency * recency_score / MAX_TIMESTAMPS
end
local function filter_timestamps(timestamps, file_id)
local res = {}
for _, entry in pairs(timestamps) do
if entry.file_id == file_id then
table.insert(res, entry)
end
end
return res
end
-- -- TODO: optimize this
-- local function find_in_table(tbl, target)
-- for _, entry in pairs(tbl) do
-- if entry.path == target then return true end
-- end
-- return false
-- end
-- local function async_callback(result)
-- -- print(vim.inspect(result))
-- end
local function filter_workspace(workspace_path, show_unindexed)
local queries = sql_wrapper.queries
show_unindexed = show_unindexed == nil and true or show_unindexed
local res = {}
res = sql_wrapper:do_transaction(queries.file_get_descendant_of, {path = workspace_path.."%"})
if type(res) == "boolean" then res = {} end -- TODO: do this in sql_wrapper:transaction
local scan_opts = {
respect_gitignore = true,
depth = 100,
hidden = true
}
-- TODO: handle duplicate entries
if show_unindexed then
local unindexed_files = scandir(workspace_path, scan_opts)
for _, file in pairs(unindexed_files) do
if not file_is_ignored(file) then -- this causes some slowdown on large dirs
table.insert(res, {
id = 0,
path = file,
count = 0,
directory_id = 0
})
end
end
end
return res
end
local updated = false
local function get_file_scores(show_unindexed, workspace_path)
if not sql_wrapper then return {} end
local queries = sql_wrapper.queries
local files = sql_wrapper:do_transaction(queries.file_get_entries, {})
local timestamp_ages = sql_wrapper:do_transaction(queries.timestamp_get_all_entry_ages, {})
local scores = {}
if vim.tbl_isempty(files) then return scores end
files = workspace_path and filter_workspace(workspace_path, show_unindexed) or files
local score
for _, file_entry in ipairs(files) do
score = file_entry.count == 0 and 0 or calculate_file_score(file_entry.count, filter_timestamps(timestamp_ages, file_entry.id))
table.insert(scores, {
filename = file_entry.path,
score = score
})
end
-- sort the table
table.sort(scores, function(a, b) return a.score > b.score end)
updated = false
return scores
end
local function autocmd_handler(filepath)
if not sql_wrapper or util.string_isempty(filepath) then return end
-- check if file is registered as loaded
if not vim.b.telescope_frecency_registered then
-- allow [noname] files to go unregistered until BufWritePost
if not util.fs_stat(filepath).exists then return end
if file_is_ignored(filepath) then return end
vim.b.telescope_frecency_registered = 1
updated = sql_wrapper:update(filepath)
end
end
local function has_updated_results()
return updated
end
return {
init = init,
get_file_scores = get_file_scores,
autocmd_handler = autocmd_handler,
validate = validate_db,
has_updated_results = has_updated_results,
}

View File

@ -1,180 +0,0 @@
local util = require("telescope._extensions.frecency.util")
local vim = vim
local has_sqlite, sqlite = pcall(require, "sqlite")
if not has_sqlite then
error("This plugin requires sqlite.lua (https://github.com/tami5/sqlite.lua) " .. tostring(sqlite))
end
-- TODO: pass in max_timestamps from db.lua
local MAX_TIMESTAMPS = 10
local db_table = {}
db_table.files = "files"
db_table.timestamps = "timestamps"
db_table.workspaces = "workspaces"
--
-- TODO: NEXT!
-- extend substr sorter to have modes:
-- when current string is prefixed by `:foo`, results are tag_names that come from tags/workspaces table. (if `:foo ` token is incomplete it is ignored)
-- when a complete workspace tag is matched ':foobar:', results are indexed_files filtered by if their parent_dir is a descendant of the workspace_dir
-- a recursive scan_dir() result is added to the :foobar: filter results; any non-indexed_files are given a score of zero, and are alphabetically sorted below the indexed_results
-- make tab completion for tab_names in insert mode`:foo|` state: cycles through available options
local M = {}
function M:new()
local o = {}
setmetatable(o, self)
self.__index = self
self.db = nil
return o
end
function M:bootstrap(db_root)
if self.db then return end
-- opts = opts or {}
-- self.max_entries = opts.max_entries or 2000
-- create the db if it doesn't exist
db_root = db_root or vim.fn.stdpath('data')
local db_filename = db_root .. "/file_frecency.sqlite3"
self.db = sqlite:open(db_filename)
if not self.db then
vim.notify("Telescope-Frecency: error in opening DB", vim.log.levels.ERROR)
return
end
local first_run = false
if not self.db:exists(db_table.files) then
first_run = true
-- create tables if they don't exist
self.db:create(db_table.files, {
id = {"INTEGER", "PRIMARY", "KEY"},
count = "INTEGER",
path = "TEXT"
})
self.db:create(db_table.timestamps, {
id = {"INTEGER", "PRIMARY", "KEY"},
file_id = "INTEGER",
timestamp = "REAL"
-- FOREIGN KEY(file_id) REFERENCES files(id)
})
end
self.db:close()
return first_run
end
--
function M:do_transaction(t, params)
-- print(vim.inspect(t))
-- print(vim.inspect(params))
return self.db:with_open(function(db)
local case = {
[1] = function() return db:select(t.cmd_data, params) end,
[2] = function() return db:insert(t.cmd_data, params) end,
[3] = function() return db:delete(t.cmd_data, params) end,
[4] = function() return db:eval(t.cmd_data, params) end,
}
return case[t.cmd]()
end)
end
local cmd = {
select = 1,
insert = 2,
delete = 3,
eval = 4,
}
local queries = {
file_get_descendant_of = {
cmd = cmd.eval,
cmd_data = "SELECT * FROM files WHERE path LIKE :path"
},
file_add_entry = {
cmd = cmd.insert,
cmd_data = db_table.files
},
file_delete_entry = {
cmd = cmd.delete,
cmd_data = db_table.files
},
file_get_entries = {
cmd = cmd.select,
cmd_data = db_table.files
},
file_update_counter = {
cmd = cmd.eval,
cmd_data = "UPDATE files SET count = count + 1 WHERE path == :path;"
},
timestamp_add_entry = {
cmd = cmd.eval,
cmd_data = "INSERT INTO timestamps (file_id, timestamp) values(:file_id, julianday('now'));"
},
timestamp_delete_entry = {
cmd = cmd.delete,
cmd_data = db_table.timestamps
},
timestamp_get_all_entries = {
cmd = cmd.select,
cmd_data = db_table.timestamps,
},
timestamp_get_all_entry_ages = {
cmd = cmd.eval,
cmd_data = "SELECT id, file_id, CAST((julianday('now') - julianday(timestamp)) * 24 * 60 AS INTEGER) AS age FROM timestamps;"
},
timestamp_delete_before_id = {
cmd = cmd.eval,
cmd_data = "DELETE FROM timestamps WHERE id < :id and file_id == :file_id;"
},
}
M.queries = queries
--
local function row_id(entry)
return (not vim.tbl_isempty(entry)) and entry[1].id or nil
end
function M:update(filepath)
local filestat = util.fs_stat(filepath)
if (vim.tbl_isempty(filestat) or
filestat.exists == false or
filestat.isdirectory == true) then
return end
-- create entry if it doesn't exist
local file_id
file_id = row_id(self:do_transaction(queries.file_get_entries, {where = {path = filepath}}))
local has_added_entry
if not file_id then
self:do_transaction(queries.file_add_entry, {path = filepath, count = 1})
file_id = row_id(self:do_transaction(queries.file_get_entries, {where = {path = filepath}}))
has_added_entry = true
else
-- ..or update existing entry
self:do_transaction(queries.file_update_counter, {path = filepath})
end
-- register timestamp for this update
self:do_transaction(queries.timestamp_add_entry, {file_id = file_id})
-- trim timestamps to MAX_TIMESTAMPS per file (there should be up to MAX_TS + 1 at this point)
local timestamps = self:do_transaction(queries.timestamp_get_all_entries, {where = {file_id = file_id}})
local trim_at = timestamps[(#timestamps - MAX_TIMESTAMPS) + 1]
if trim_at then
self:do_transaction(queries.timestamp_delete_before_id, {id = trim_at.id, file_id = file_id})
end
return has_added_entry
end
return M

View File

@ -1,44 +0,0 @@
local uv = vim.loop
local util = {}
-- stolen from penlight
-- escape any Lua 'magic' characters in a string
util.escape = function(str)
return (str:gsub('[%-%.%+%[%]%(%)%$%^%%%?%*]','%%%1'))
end
util.filemask = function(mask)
mask = util.escape(mask)
return '^'..mask:gsub('%%%*','.*'):gsub('%%%?','.')..'$'
end
util.filename_match = function(filename, pattern)
return filename:find(util.filemask(pattern)) ~= nil
end
--
util.string_isempty = function(str)
return str == nil or str == ''
end
util.split = function(str, delimiter)
local result = {}
for match in str:gmatch("[^" .. delimiter .. "]+") do
table.insert(result, match)
end
return result
end
util.fs_stat = function(path)
local stat = uv.fs_stat(path)
local res = {}
res.exists = stat and true or false -- TODO: this is silly
res.isdirectory = (stat and stat.type == "directory") and true or false
return res
end
return util