diff --git a/doc/telescope-frecency.txt b/doc/telescope-frecency.txt index a8528ae..52dcf07 100644 --- a/doc/telescope-frecency.txt +++ b/doc/telescope-frecency.txt @@ -134,6 +134,9 @@ If no database is found when running Neovim with the plugin installed, a new one is created and entries from |shada| and |v:oldfiles| are automatically imported. +NOTE: Even if you want to load |telescope.nvim| lazily, you should NOT load +telescope-frecency.nvim lazily. See |telescope-frecency-function-setup|. + ============================================================================== USAGE *telescope-frecency-usage* @@ -315,6 +318,46 @@ Options: *telescope-frecency-function-query-options* exist below the directory specified this value. See also |telescope-frecency-usage|. + *telescope-frecency-function-setup* +setup() ~ + +This will be called by |telescope.nvim| for its initialization. You can also +call this to initialize this plugin separated from |telescope.nvim|'s +initialization phase. + +This is useful when you want to load |telescope.nvim| lazily, but want to +register opened files as soon as Neovim has started. Example configuration for +|lazy.nvim| is below. +>lua + { + "nvim-telescope/telescope-frecency.nvim", + main = "frecency", + ---@type FrecencyOpts + opts = { + db_safe_mode = false, + }, + }, + + { + "nvim-telescope/telescope.nvim", + -- `cmd` opts makes lazy.nvim load telescope.nvim lazily. + cmd = { "Telescope" }, + config = function() + local telescope = require "telescope" + telescope.setup { + extensions = { + other_extension = { + foo_bar = true, + }, + -- Here you need no configuration opts for frecency because + -- you've already done. + } + } + -- This is still needed. + telescope.load_extension "frecency" + end, + }, + ============================================================================== CONFIGURATION *telescope-frecency-configuration* diff --git a/lua/frecency/init.lua b/lua/frecency/init.lua index 795d6cf..38f1dd5 100644 --- a/lua/frecency/init.lua +++ b/lua/frecency/init.lua @@ -1,261 +1,81 @@ -local Database = require "frecency.database" -local EntryMaker = require "frecency.entry_maker" -local FS = require "frecency.fs" -local Picker = require "frecency.picker" -local Recency = require "frecency.recency" -local config = require "frecency.config" -local log = require "frecency.log" - ----@class Frecency ----@field private buf_registered table flag to indicate the buffer is registered to the database. ----@field private database FrecencyDatabase ----@field private entry_maker FrecencyEntryMaker ----@field private fs FrecencyFS ----@field private picker FrecencyPicker ----@field private recency FrecencyRecency -local Frecency = {} - ----@return Frecency -Frecency.new = function() - local self = setmetatable({ buf_registered = {} }, { __index = Frecency }) --[[@as Frecency]] - self.fs = FS.new() - self.database = Database.new(self.fs) - self.entry_maker = EntryMaker.new(self.fs) - self.recency = Recency.new() - return self -end - ----This is called when `:Telescope frecency` is called at the first time. ----@return nil -function Frecency:setup() - -- HACK: Wihout this wrapping, it spoils background color detection. - -- See https://github.com/nvim-telescope/telescope-frecency.nvim/issues/210 - vim.defer_fn(function() - self:assert_db_entries() - if config.auto_validate then - self:validate_database() +---This object is intended to be used as a singleton, and is lazily loaded. +---When methods are called at the first time, it calls the constructor and +---setup() to be initialized. +---@class FrecencyInstance +---@field complete fun(findstart: 1|0, base: string): integer|''|string[] +---@field delete fun(path: string): nil +---@field query fun(opts?: FrecencyQueryOpts): FrecencyQueryEntry[]|string[] +---@field register fun(bufnr: integer, datetime: string?): nil +---@field start fun(opts: FrecencyPickerOptions?): nil +---@field validate_database fun(force: boolean?): nil +local frecency = setmetatable({}, { + ---@param self FrecencyInstance + ---@param key "complete"|"delete"|"register"|"start"|"validate_database" + ---@return function + __index = function(self, key) + ---@return Frecency + local function instance() + return rawget(self, "instance") end - end, 0) -end ----This can be calledBy `require("telescope").extensions.frecency.frecency`. ----@param opts? FrecencyPickerOptions + return function(...) + if not instance() then + rawset(self, "instance", require("frecency.klass").new()) + instance():setup() + end + return instance()[key](instance(), ...) + end + end, +}) + +local setup_done = false + +---When this func is called, Frecency instance is NOT created but only +---configuration is done. +---@param ext_config? FrecencyOpts ---@return nil -function Frecency:start(opts) - local start = os.clock() - log.debug "Frecency:start" - opts = opts or {} - if opts.cwd then - opts.cwd = vim.fn.expand(opts.cwd) - end - local ignore_filenames - if opts.hide_current_buffer or config.hide_current_buffer then - ignore_filenames = { vim.api.nvim_buf_get_name(0) } - end - self.picker = Picker.new(self.database, self.entry_maker, self.fs, self.recency, { - editing_bufnr = vim.api.nvim_get_current_buf(), - ignore_filenames = ignore_filenames, - initial_workspace_tag = opts.workspace, - }) - self.picker:start(vim.tbl_extend("force", config.get(), opts)) - log.debug(("Frecency:start picker:start takes %f seconds"):format(os.clock() - start)) -end - ----This can be calledBy `require("telescope").extensions.frecency.complete`. ----@param findstart 1|0 ----@param base string ----@return integer|''|string[] -function Frecency:complete(findstart, base) - return self.picker:complete(findstart, base) -end - ----@private ----@return nil -function Frecency:assert_db_entries() - if not self.database:has_entry() then - self.database:insert_files(vim.v.oldfiles) - self:notify("Imported %d entries from oldfiles.", #vim.v.oldfiles) - end -end - ----@private ----@param force? boolean ----@return nil -function Frecency:validate_database(force) - local unlinked = self.database:unlinked_entries() - if #unlinked == 0 or (not force and #unlinked < config.db_validate_threshold) then +local function setup(ext_config) + if setup_done then return end - local function remove_entries() - self.database:remove_files(unlinked) - self:notify("removed %d missing entries.", #unlinked) - end - if not config.db_safe_mode then - remove_entries() - return - end - vim.ui.select({ "y", "n" }, { - prompt = self:message("remove %d entries from database?", #unlinked), - ---@param item "y"|"n" - ---@return string - format_item = function(item) - return item == "y" and "Yes. Remove them." or "No. Do nothing." + + require("frecency.config").setup(ext_config) + + vim.api.nvim_set_hl(0, "TelescopeBufferLoaded", { link = "String", default = true }) + vim.api.nvim_set_hl(0, "TelescopePathSeparator", { link = "Directory", default = true }) + vim.api.nvim_set_hl(0, "TelescopeFrecencyScores", { link = "Number", default = true }) + vim.api.nvim_set_hl(0, "TelescopeQueryFilter", { link = "WildMenu", default = true }) + + ---@param cmd_info { bang: boolean } + vim.api.nvim_create_user_command("FrecencyValidate", function(cmd_info) + frecency.validate_database(cmd_info.bang) + end, { bang = true, desc = "Clean up DB for telescope-frecency" }) + + vim.api.nvim_create_user_command("FrecencyDelete", function(info) + local path_string = info.args == "" and "%:p" or info.args + local path = vim.fn.expand(path_string) --[[@as string]] + frecency.delete(path) + end, { nargs = "?", complete = "file", desc = "Delete entry from telescope-frecency" }) + + local group = vim.api.nvim_create_augroup("TelescopeFrecency", {}) + vim.api.nvim_create_autocmd({ "BufWinEnter", "BufWritePost" }, { + desc = "Update database for telescope-frecency", + group = group, + ---@param args { buf: integer } + callback = function(args) + local is_floatwin = vim.api.nvim_win_get_config(0).relative ~= "" + if not is_floatwin then + frecency.register(args.buf) + end end, - }, function(item) - if item == "y" then - remove_entries() - else - self:notify "validation aborted" - end - end) + }) + + setup_done = true end ----@param bufnr integer ----@param epoch? integer -function Frecency:register(bufnr, epoch) - if config.ignore_register and config.ignore_register(bufnr) then - return - end - local path = vim.api.nvim_buf_get_name(bufnr) - if self.buf_registered[bufnr] or not self.fs:is_valid_path(path) then - return - end - self.database:update(path, epoch) - self.buf_registered[bufnr] = true -end - ----@param path string ----@return nil -function Frecency:delete(path) - if self.database:remove_entry(path) then - self:notify("successfully deleted: %s", path) - else - self:warn("failed to delete: %s", path) - end -end - ----@alias FrecencyQueryOrder "count"|"path"|"score"|"timestamps" ----@alias FrecencyQueryDirection "asc"|"desc" - ----@class FrecencyQueryOpts ----@field direction? "asc"|"desc" default: "desc" ----@field limit? integer default: 100 ----@field order? FrecencyQueryOrder default: "score" ----@field record? boolean default: false ----@field workspace? string default: nil - ----@class FrecencyQueryEntry ----@field count integer ----@field path string ----@field score number ----@field timestamps integer[] - ----@param opts? FrecencyQueryOpts ----@param epoch? integer ----@return FrecencyQueryEntry[]|string[] -function Frecency:query(opts, epoch) - opts = vim.tbl_extend("force", { - direction = "desc", - limit = 100, - order = "score", - record = false, - }, opts or {}) - ---@param entry FrecencyDatabaseEntry - local entries = vim.tbl_map(function(entry) - return { - count = entry.count, - path = entry.path, - score = entry.ages and self.recency:calculate(entry.count, entry.ages) or 0, - timestamps = entry.timestamps, - } - end, self.database:get_entries(opts.workspace, epoch)) - table.sort(entries, self:query_sorter(opts.order, opts.direction)) - local results = opts.record and entries or vim.tbl_map(function(entry) - return entry.path - end, entries) - if #results > opts.limit then - return vim.list_slice(results, 1, opts.limit) - end - return results -end - ----@private ----@param order FrecencyQueryOrder ----@param direction FrecencyQueryDirection ----@return fun(a: FrecencyQueryEntry, b: FrecencyQueryEntry): boolean -function Frecency:query_sorter(order, direction) - local is_asc = direction == "asc" - if order == "count" then - if is_asc then - return function(a, b) - return a.count < b.count or (a.count == b.count and a.path < b.path) - end - end - return function(a, b) - return a.count > b.count or (a.count == b.count and a.path < b.path) - end - elseif order == "path" then - if is_asc then - return function(a, b) - return a.path < b.path - end - end - return function(a, b) - return a.path > b.path - end - elseif order == "score" then - if is_asc then - return function(a, b) - return a.score < b.score or (a.score == b.score and a.path < b.path) - end - end - return function(a, b) - return a.score > b.score or (a.score == b.score and a.path < b.path) - end - elseif is_asc then - return function(a, b) - local a_timestamp = a.timestamps[1] or 0 - local b_timestamp = b.timestamps[1] or 0 - return a_timestamp < b_timestamp or (a_timestamp == b_timestamp and a.path < b.path) - end - end - return function(a, b) - local a_timestamp = a.timestamps[1] or 0 - local b_timestamp = b.timestamps[1] or 0 - return a_timestamp > b_timestamp or (a_timestamp == b_timestamp and a.path < b.path) - end -end - ----@private ----@param fmt string ----@param ...? any ----@return string -function Frecency:message(fmt, ...) - return ("[Telescope-Frecency] " .. fmt):format(unpack { ... }) -end - ----@private ----@param fmt string ----@param ...? any ----@return nil -function Frecency:notify(fmt, ...) - vim.notify(self:message(fmt, ...)) -end - ----@private ----@param fmt string ----@param ...? any ----@return nil -function Frecency:warn(fmt, ...) - vim.notify(self:message(fmt, ...), vim.log.levels.WARN) -end - ----@private ----@param fmt string ----@param ...? any ----@return nil -function Frecency:error(fmt, ...) - vim.notify(self:message(fmt, ...), vim.log.levels.ERROR) -end - -return Frecency +return { + start = frecency.start, + complete = frecency.complete, + query = frecency.query, + setup = setup, +} diff --git a/lua/frecency/klass.lua b/lua/frecency/klass.lua new file mode 100644 index 0000000..795d6cf --- /dev/null +++ b/lua/frecency/klass.lua @@ -0,0 +1,261 @@ +local Database = require "frecency.database" +local EntryMaker = require "frecency.entry_maker" +local FS = require "frecency.fs" +local Picker = require "frecency.picker" +local Recency = require "frecency.recency" +local config = require "frecency.config" +local log = require "frecency.log" + +---@class Frecency +---@field private buf_registered table flag to indicate the buffer is registered to the database. +---@field private database FrecencyDatabase +---@field private entry_maker FrecencyEntryMaker +---@field private fs FrecencyFS +---@field private picker FrecencyPicker +---@field private recency FrecencyRecency +local Frecency = {} + +---@return Frecency +Frecency.new = function() + local self = setmetatable({ buf_registered = {} }, { __index = Frecency }) --[[@as Frecency]] + self.fs = FS.new() + self.database = Database.new(self.fs) + self.entry_maker = EntryMaker.new(self.fs) + self.recency = Recency.new() + return self +end + +---This is called when `:Telescope frecency` is called at the first time. +---@return nil +function Frecency:setup() + -- HACK: Wihout this wrapping, it spoils background color detection. + -- See https://github.com/nvim-telescope/telescope-frecency.nvim/issues/210 + vim.defer_fn(function() + self:assert_db_entries() + if config.auto_validate then + self:validate_database() + end + end, 0) +end + +---This can be calledBy `require("telescope").extensions.frecency.frecency`. +---@param opts? FrecencyPickerOptions +---@return nil +function Frecency:start(opts) + local start = os.clock() + log.debug "Frecency:start" + opts = opts or {} + if opts.cwd then + opts.cwd = vim.fn.expand(opts.cwd) + end + local ignore_filenames + if opts.hide_current_buffer or config.hide_current_buffer then + ignore_filenames = { vim.api.nvim_buf_get_name(0) } + end + self.picker = Picker.new(self.database, self.entry_maker, self.fs, self.recency, { + editing_bufnr = vim.api.nvim_get_current_buf(), + ignore_filenames = ignore_filenames, + initial_workspace_tag = opts.workspace, + }) + self.picker:start(vim.tbl_extend("force", config.get(), opts)) + log.debug(("Frecency:start picker:start takes %f seconds"):format(os.clock() - start)) +end + +---This can be calledBy `require("telescope").extensions.frecency.complete`. +---@param findstart 1|0 +---@param base string +---@return integer|''|string[] +function Frecency:complete(findstart, base) + return self.picker:complete(findstart, base) +end + +---@private +---@return nil +function Frecency:assert_db_entries() + if not self.database:has_entry() then + self.database:insert_files(vim.v.oldfiles) + self:notify("Imported %d entries from oldfiles.", #vim.v.oldfiles) + end +end + +---@private +---@param force? boolean +---@return nil +function Frecency:validate_database(force) + local unlinked = self.database:unlinked_entries() + if #unlinked == 0 or (not force and #unlinked < config.db_validate_threshold) then + return + end + local function remove_entries() + self.database:remove_files(unlinked) + self:notify("removed %d missing entries.", #unlinked) + end + if not config.db_safe_mode then + remove_entries() + return + end + vim.ui.select({ "y", "n" }, { + prompt = self:message("remove %d entries from database?", #unlinked), + ---@param item "y"|"n" + ---@return string + format_item = function(item) + return item == "y" and "Yes. Remove them." or "No. Do nothing." + end, + }, function(item) + if item == "y" then + remove_entries() + else + self:notify "validation aborted" + end + end) +end + +---@param bufnr integer +---@param epoch? integer +function Frecency:register(bufnr, epoch) + if config.ignore_register and config.ignore_register(bufnr) then + return + end + local path = vim.api.nvim_buf_get_name(bufnr) + if self.buf_registered[bufnr] or not self.fs:is_valid_path(path) then + return + end + self.database:update(path, epoch) + self.buf_registered[bufnr] = true +end + +---@param path string +---@return nil +function Frecency:delete(path) + if self.database:remove_entry(path) then + self:notify("successfully deleted: %s", path) + else + self:warn("failed to delete: %s", path) + end +end + +---@alias FrecencyQueryOrder "count"|"path"|"score"|"timestamps" +---@alias FrecencyQueryDirection "asc"|"desc" + +---@class FrecencyQueryOpts +---@field direction? "asc"|"desc" default: "desc" +---@field limit? integer default: 100 +---@field order? FrecencyQueryOrder default: "score" +---@field record? boolean default: false +---@field workspace? string default: nil + +---@class FrecencyQueryEntry +---@field count integer +---@field path string +---@field score number +---@field timestamps integer[] + +---@param opts? FrecencyQueryOpts +---@param epoch? integer +---@return FrecencyQueryEntry[]|string[] +function Frecency:query(opts, epoch) + opts = vim.tbl_extend("force", { + direction = "desc", + limit = 100, + order = "score", + record = false, + }, opts or {}) + ---@param entry FrecencyDatabaseEntry + local entries = vim.tbl_map(function(entry) + return { + count = entry.count, + path = entry.path, + score = entry.ages and self.recency:calculate(entry.count, entry.ages) or 0, + timestamps = entry.timestamps, + } + end, self.database:get_entries(opts.workspace, epoch)) + table.sort(entries, self:query_sorter(opts.order, opts.direction)) + local results = opts.record and entries or vim.tbl_map(function(entry) + return entry.path + end, entries) + if #results > opts.limit then + return vim.list_slice(results, 1, opts.limit) + end + return results +end + +---@private +---@param order FrecencyQueryOrder +---@param direction FrecencyQueryDirection +---@return fun(a: FrecencyQueryEntry, b: FrecencyQueryEntry): boolean +function Frecency:query_sorter(order, direction) + local is_asc = direction == "asc" + if order == "count" then + if is_asc then + return function(a, b) + return a.count < b.count or (a.count == b.count and a.path < b.path) + end + end + return function(a, b) + return a.count > b.count or (a.count == b.count and a.path < b.path) + end + elseif order == "path" then + if is_asc then + return function(a, b) + return a.path < b.path + end + end + return function(a, b) + return a.path > b.path + end + elseif order == "score" then + if is_asc then + return function(a, b) + return a.score < b.score or (a.score == b.score and a.path < b.path) + end + end + return function(a, b) + return a.score > b.score or (a.score == b.score and a.path < b.path) + end + elseif is_asc then + return function(a, b) + local a_timestamp = a.timestamps[1] or 0 + local b_timestamp = b.timestamps[1] or 0 + return a_timestamp < b_timestamp or (a_timestamp == b_timestamp and a.path < b.path) + end + end + return function(a, b) + local a_timestamp = a.timestamps[1] or 0 + local b_timestamp = b.timestamps[1] or 0 + return a_timestamp > b_timestamp or (a_timestamp == b_timestamp and a.path < b.path) + end +end + +---@private +---@param fmt string +---@param ...? any +---@return string +function Frecency:message(fmt, ...) + return ("[Telescope-Frecency] " .. fmt):format(unpack { ... }) +end + +---@private +---@param fmt string +---@param ...? any +---@return nil +function Frecency:notify(fmt, ...) + vim.notify(self:message(fmt, ...)) +end + +---@private +---@param fmt string +---@param ...? any +---@return nil +function Frecency:warn(fmt, ...) + vim.notify(self:message(fmt, ...), vim.log.levels.WARN) +end + +---@private +---@param fmt string +---@param ...? any +---@return nil +function Frecency:error(fmt, ...) + vim.notify(self:message(fmt, ...), vim.log.levels.ERROR) +end + +return Frecency diff --git a/lua/frecency/tests/frecency_spec.lua b/lua/frecency/tests/frecency_spec.lua index 8aaffa4..bd4f1a3 100644 --- a/lua/frecency/tests/frecency_spec.lua +++ b/lua/frecency/tests/frecency_spec.lua @@ -3,7 +3,7 @@ vim.opt.runtimepath:append(vim.env.TELESCOPE_PATH) ---@diagnostic disable: invisible, undefined-field -local Frecency = require "frecency" +local Frecency = require "frecency.klass" local Picker = require "frecency.picker" local util = require "frecency.tests.util" local log = require "plenary.log" diff --git a/lua/telescope/_extensions/frecency.lua b/lua/telescope/_extensions/frecency.lua index 728f4c3..d72303d 100644 --- a/lua/telescope/_extensions/frecency.lua +++ b/lua/telescope/_extensions/frecency.lua @@ -1,32 +1,4 @@ ----This object is intended to be used as a singleton, and is lazily loaded. ----When methods are called at the first time, it calls the constructor and ----setup() to be initialized. ----@class FrecencyInstance ----@field complete fun(findstart: 1|0, base: string): integer|''|string[] ----@field delete fun(path: string): nil ----@field query fun(opts?: FrecencyQueryOpts): FrecencyQueryEntry[]|string[] ----@field register fun(bufnr: integer, datetime: string?): nil ----@field start fun(opts: FrecencyPickerOptions?): nil ----@field validate_database fun(force: boolean?): nil -local frecency = setmetatable({}, { - ---@param self FrecencyInstance - ---@param key "complete"|"delete"|"register"|"start"|"validate_database" - ---@return function - __index = function(self, key) - ---@return Frecency - local function instance() - return rawget(self, "instance") - end - - return function(...) - if not instance() then - rawset(self, "instance", require("frecency").new()) - instance():setup() - end - return instance()[key](instance(), ...) - end - end, -}) +local frecency = require "frecency" return require("telescope").register_extension { exports = { @@ -34,42 +6,7 @@ return require("telescope").register_extension { complete = frecency.complete, query = frecency.query, }, - - ---When this func is called, Frecency instance is NOT created but only - ---configuration is done. - setup = function(ext_config) - require("frecency.config").setup(ext_config) - - vim.api.nvim_set_hl(0, "TelescopeBufferLoaded", { link = "String", default = true }) - vim.api.nvim_set_hl(0, "TelescopePathSeparator", { link = "Directory", default = true }) - vim.api.nvim_set_hl(0, "TelescopeFrecencyScores", { link = "Number", default = true }) - vim.api.nvim_set_hl(0, "TelescopeQueryFilter", { link = "WildMenu", default = true }) - - ---@param cmd_info { bang: boolean } - vim.api.nvim_create_user_command("FrecencyValidate", function(cmd_info) - frecency.validate_database(cmd_info.bang) - end, { bang = true, desc = "Clean up DB for telescope-frecency" }) - - vim.api.nvim_create_user_command("FrecencyDelete", function(info) - local path_string = info.args == "" and "%:p" or info.args - local path = vim.fn.expand(path_string) --[[@as string]] - frecency.delete(path) - end, { nargs = "?", complete = "file", desc = "Delete entry from telescope-frecency" }) - - local group = vim.api.nvim_create_augroup("TelescopeFrecency", {}) - vim.api.nvim_create_autocmd({ "BufWinEnter", "BufWritePost" }, { - desc = "Update database for telescope-frecency", - group = group, - ---@param args { buf: integer } - callback = function(args) - local is_floatwin = vim.api.nvim_win_get_config(0).relative ~= "" - if not is_floatwin then - frecency.register(args.buf) - end - end, - }) - end, - + setup = frecency.setup, health = function() if vim.F.npcall(require, "nvim-web-devicons") then vim.health.ok "nvim-web-devicons installed."