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