diff --git a/.stylua.toml b/.stylua.toml new file mode 100644 index 0000000..df96b7b --- /dev/null +++ b/.stylua.toml @@ -0,0 +1,6 @@ +column_width = 120 +line_endings = "Unix" +indent_type = "Spaces" +indent_width = 2 +quote_style = "AutoPreferDouble" +no_call_parentheses = true diff --git a/lua/frecency/algo.lua b/lua/frecency/algo.lua new file mode 100644 index 0000000..9830ae3 --- /dev/null +++ b/lua/frecency/algo.lua @@ -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 diff --git a/lua/frecency/const.lua b/lua/frecency/const.lua new file mode 100644 index 0000000..8489731 --- /dev/null +++ b/lua/frecency/const.lua @@ -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://*", + }, +} diff --git a/lua/frecency/db.lua b/lua/frecency/db.lua new file mode 100644 index 0000000..658c9ba --- /dev/null +++ b/lua/frecency/db.lua @@ -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 diff --git a/lua/frecency/picker.lua b/lua/frecency/picker.lua new file mode 100644 index 0000000..d85e8d1 --- /dev/null +++ b/lua/frecency/picker.lua @@ -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", "", "pumvisible() ? '' : ''", { buffer = buffer, expr = true }) + vim.keymap.set("i", "", "pumvisible() ? '' : ''", { 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 "" or ":" + 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 diff --git a/lua/frecency/util.lua b/lua/frecency/util.lua new file mode 100644 index 0000000..fcec991 --- /dev/null +++ b/lua/frecency/util.lua @@ -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 diff --git a/lua/telescope/_extensions/frecency.lua b/lua/telescope/_extensions/frecency.lua index 9714aad..28e9d81 100644 --- a/lua/telescope/_extensions/frecency.lua +++ b/lua/telescope/_extensions/frecency.lua @@ -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 "" or ":" - 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", "", "pumvisible() ? '' : ''", { buffer = buffer, expr = true }) - vim.keymap.set("i", "", "pumvisible() ? '' : ''", { 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, } diff --git a/lua/telescope/_extensions/frecency/db_client.lua b/lua/telescope/_extensions/frecency/db_client.lua deleted file mode 100644 index f5dcf04..0000000 --- a/lua/telescope/_extensions/frecency/db_client.lua +++ /dev/null @@ -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, -} diff --git a/lua/telescope/_extensions/frecency/sql_wrapper.lua b/lua/telescope/_extensions/frecency/sql_wrapper.lua deleted file mode 100644 index d9bb55a..0000000 --- a/lua/telescope/_extensions/frecency/sql_wrapper.lua +++ /dev/null @@ -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 diff --git a/lua/telescope/_extensions/frecency/util.lua b/lua/telescope/_extensions/frecency/util.lua deleted file mode 100644 index 7feddfa..0000000 --- a/lua/telescope/_extensions/frecency/util.lua +++ /dev/null @@ -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