diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dfec94e..e1d7b54 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -75,6 +75,7 @@ Start each test file with `local T = helpers.new_set()`. The hooks of this set i read `helpers.notifications`. - They unset `GITHUB_API_TOKEN` and `GITLAB_TOKEN`. - They isolate git from your user config and your system config. +- They set `XDG_CACHE_HOME` to a temporary directory, so that the cache of the GitHub and GitLab items is empty. - They restore the cwd, the buffers, and the values that `helpers.stub` replaced. - They delete the directories from `helpers.tempdir` and `helpers.git_repo`. @@ -116,7 +117,8 @@ until the completion items show. Nothing in them uses the network. The `e2e/` cases run the real git commands. A fake `gh` in a temporary directory answers the GitHub requests at once, so the times include the process starts, but not the network or the run time of the real `gh`. `cold` cases use a new -source for each request. `warm` cases repeat a request that the cache can answer. +source and an empty cache for each request. `warm` cases repeat a request that the cache can answer. `disk` cases use a +new source and the cache files of an earlier request, as in a new Neovim. The `startup/` cases measure the cost to load cmp-git. `startup/setup` is the cost of `setup`. Core loads the sources and the repository module when a request first needs them, so `startup/require_all` loads all the modules. The test diff --git a/Makefile b/Makefile index 6822879..ba3ea6a 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ SELENE ?= selene LUA_LANGUAGE_SERVER ?= lua-language-server MINI_VERSION := v0.18.0 MINI_DIR := deps/mini.nvim -LUA_PATHS := lua tests benchmarks +LUA_PATHS := lua plugin tests benchmarks REF ?= main .PHONY: test-deps test test-file bench bench-compare format format-check lint typecheck ci diff --git a/README.md b/README.md index 8091f09..aaeef04 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,9 @@ require("cmp_git").setup({ remotes = { "upstream", "origin" }, -- in order of most to least prioritized enableRemoteUrlRewrites = false, -- enable git url rewrites, see https://git-scm.com/docs/git-config#Documentation/git-config.txt-urlltbasegtinsteadOf ssh_aliases = {}, -- SSH host aliases and their real hosts, such as { ["github-work"] = "github.com" } + cache = { + ttl = 300, -- seconds after which a request also gets the GitHub and GitLab items again in the background + }, git = { commits = { limit = 100, @@ -254,6 +257,7 @@ require("cmp_git").setup({ - [Remotes and SSH aliases](docs/configuration.md#remotes-and-ssh-aliases) - [GitHub CLI, GitLab CLI, and curl](docs/configuration.md#github-cli-gitlab-cli-and-curl) - [Hosted GitHub and GitLab instances](docs/configuration.md#hosted-github-and-gitlab-instances) +- [Cache](docs/configuration.md#cache), and the `:CmpGitClearCache` command - [Troubleshooting](docs/configuration.md#troubleshooting) ## Contributing diff --git a/benchmarks/cases/e2e.lua b/benchmarks/cases/e2e.lua index 64e67a9..b4f8624 100644 --- a/benchmarks/cases/e2e.lua +++ b/benchmarks/cases/e2e.lua @@ -1,7 +1,7 @@ ---Completion requests from start to the final callback, in a git repository with 1000 commits. Each request runs the ---real git commands and a fake `gh` (see `fixtures.isolate_environment`), so the times include process starts but no ----network. `cold` cases use a new source for each request, and `warm` cases repeat a request that the cache can ----answer. +---network. `cold` cases use a new source and an empty cache for each request, and `warm` cases repeat a request that +---the cache can answer. `disk` cases use a new source and the cache files of an earlier request, as in a new session. local fixtures = require("benchmarks.fixtures") local COMMITS = 1000 @@ -26,13 +26,22 @@ local REQUESTS = { github_mentions = { row = 3, line = "See @", trigger_character = "@" }, } +---Remove the cached GitHub and GitLab items from memory and from disk. Revisions before the disk cache have no cache +---module, and a new source is cold there. +local function clear_cache() + local ok, cache = pcall(require, "cmp_git.cache") + if ok then + cache.clear_all() + end +end + ---@param name string A key of `REQUESTS`. ----@param cold boolean +---@param mode "cold" | "warm" | "disk" ---@return bench.AsyncCase -local function request_case(name, cold) +local function request_case(name, mode) local request = REQUESTS[name] return { - name = string.format("e2e/%s/%s", name, cold and "cold" or "warm"), + name = string.format("e2e/%s/%s", name, mode), async = true, setup = function() local dir = fixtures.repo(COMMITS) @@ -43,7 +52,13 @@ local function request_case(name, cold) end, before_each = function(state) vim.api.nvim_set_current_buf(state.bufnr) - if cold then + if mode == "cold" then + clear_cache() + elseif mode == "disk" then + -- The warm-up run writes the files + require("cmp_git.cache").forget() + end + if mode ~= "warm" then state.core = require("cmp_git.core").new({}) end end, @@ -77,8 +92,12 @@ for _, name in ipairs({ "github_issues_and_prs", "github_mentions", }) do - table.insert(cases, request_case(name, true)) - table.insert(cases, request_case(name, false)) + table.insert(cases, request_case(name, "cold")) + table.insert(cases, request_case(name, "warm")) +end +-- Only the GitHub and GitLab items have cache files +for _, name in ipairs({ "github_issues_and_prs", "github_mentions" }) do + table.insert(cases, request_case(name, "disk")) end return cases diff --git a/benchmarks/fixtures.lua b/benchmarks/fixtures.lua index 0f71a6b..caa4625 100644 --- a/benchmarks/fixtures.lua +++ b/benchmarks/fixtures.lua @@ -296,6 +296,7 @@ function M.isolate_environment() vim.env.HOME = home vim.env.XDG_CONFIG_HOME = home .. "/.config" + vim.env.XDG_CACHE_HOME = home .. "/.cache" vim.env.GIT_CONFIG_GLOBAL = "/dev/null" vim.env.GIT_CONFIG_NOSYSTEM = "1" vim.env.GIT_CEILING_DIRECTORIES = root diff --git a/docs/configuration.md b/docs/configuration.md index 0fe5719..932485b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -304,6 +304,46 @@ the path prefix for this remote when these conditions are true: Path prefixes are only for GitLab. GitHub Enterprise does not use a path prefix. +## Cache + +cmp-git keeps the GitHub and GitLab items in a cache on disk: the issues, the pull requests, the merge requests, and +the mentions. The cache is in `stdpath("cache")/cmp-git`, usually `~/.cache/nvim/cmp-git`, with a directory for each +remote, such as `github.com/owner/repo`. All buffers of a remote use the same items, also in a new Neovim. So the first +`#`, `!`, or `@` in a new commit message shows the items without a wait for the network. + +The remote is the first remote in `remotes` that has a supported URL. For GitHub, the items come from the repository +that `gh repo set-default` chose, if any. A change of the default repository shows after the next refresh. + +When the items are older than `cache.ttl` seconds, a request still shows them at once. The request also gets the items +again in the background, and the next request shows the new items. The default TTL is 300 seconds: + +```lua +require("cmp_git").setup({ + cache = { + ttl = 300, + }, +}) +``` + +With `ttl = 0`, each request gets the items again in the background. With `ttl = math.huge`, cmp-git never gets them +again. + +cmp-git does not cache the result of a failed command or an HTTP error, so the next request tries again. If a +background refresh fails, cmp-git keeps the old items and tries again after `cache.ttl` seconds. + +The cache holds the titles and the bodies of the issues and the pull requests, and the users of the repository, also +for private repositories. Only your user can read the cache files. + +To clear the cache, use these commands: + +- `:CmpGitClearCache` clears the items of the remote of the current buffer. It also clears the commits and the + trailers of the buffer. +- `:CmpGitClearCache!` clears the items of all remotes, and the commits and the trailers of all buffers. + +The commits and the trailers come from the local repository. cmp-git keeps them in memory for each buffer and does not +refresh them. To get new commits and trailers, run `:CmpGitClearCache`, or wipe the buffer with `:bwipeout` and open +the file again. + ## Troubleshooting If no items show, make sure that these conditions are true: @@ -316,6 +356,4 @@ If no items show, make sure that these conditions are true: To log missing executables, and the exit code and stderr of failed commands, set `vim.g.cmp_git_debug = true`. cmp-git sends the messages with `vim.notify`. -cmp-git caches the items for each buffer and does not refresh them. To get new items, wipe the buffer with -`:bwipeout` and open the file again, or restart Neovim. cmp-git does not cache the result of a failed command or an -HTTP error, so the next request tries again. +If the items are old or missing, run `:CmpGitClearCache`. The next request gets them again. See [Cache](#cache). diff --git a/lua/cmp_git/cache.lua b/lua/cmp_git/cache.lua new file mode 100644 index 0000000..9e9a0fe --- /dev/null +++ b/lua/cmp_git/cache.lua @@ -0,0 +1,247 @@ +-- The cache of the GitHub and GitLab items. An entry holds the raw items of one list of one remote, and a file under +-- `stdpath("cache")/cmp-git` keeps them for the next sessions. The items are raw and not completion items, because the +-- formats are user functions, and the sort text depends on the time of the request. +local log = require("cmp_git.log") + +local M = {} + +-- A file with another version is a miss +local VERSION = 1 + +-- The cache can hold the issues of private repositories, so only the user can read it +local PRIVATE_DIR = tonumber("700", 8) +local PRIVATE_FILE = tonumber("600", 8) + +-- The config fields that change the query of a list. A list with other values has another entry. +local QUERY_FIELDS = { "state", "filter", "limit", "fields" } + +---The raw items of one list of one remote, in memory and in a file. +---@class cmp_git.CacheEntry +---@field path string The file of the entry. +---@field dir string The directory of the remote. +---@field items? table[] nil until a fetch or the file gives the items. +---@field fetched_at? integer When the items were fetched, in seconds since the epoch. +---@field failed_at? integer When the last refresh failed, in seconds since the epoch. +---@field loaded boolean The file was read. +---@field refreshing boolean A background refresh runs. +---@field dropped boolean A clear removed the entry, so it keeps no more items. +---@field fetch? cmp_git.Provider.MentionFetch The fetch in progress of the mentions. It is only in memory. +local Entry = {} +Entry.__index = Entry + +---@type table By path. +local entries = {} + +---The query hash of each capability config +---@type table +local query_hashes = setmetatable({}, { __mode = "k" }) + +---Encode a name as one path segment. A `/` in a GitLab group or in a host with a path prefix becomes `%2F`, and a `:` +---before a port becomes `%3A`. +---@param name string +---@return string +local function encode(name) + return ( + string.gsub(name, "[^%w._-]", function(char) + return string.format("%%%02X", string.byte(char)) + end) + ) +end + +---The directory of all cache files. +---@return string +function M.root() + return vim.fs.joinpath(vim.fn.stdpath("cache") --[[@as string]], "cmp-git") +end + +---@param git_info cmp_git.GitInfo +---@return string +local function remote_dir(git_info) + return vim.fs.joinpath(M.root(), encode(git_info.host), encode(git_info.owner), encode(git_info.repo)) +end + +---@param config table +---@return string +local function query_hash(config) + local hash = query_hashes[config] + if hash == nil then + local query = {} + for _, field in ipairs(QUERY_FIELDS) do + query[field] = config[field] + end + -- `vim.inspect` sorts the keys, so equal queries give equal text + hash = string.sub(vim.fn.sha256(vim.inspect(query)), 1, 16) + query_hashes[config] = hash + end + return hash +end + +---@param path string +---@return { fetched_at: integer, items: table[] }? +local function read(path) + local file = io.open(path, "rb") + if file == nil then + return nil + end + local content = file:read("*a") + file:close() + + local ok, data = pcall(vim.json.decode, content or "") + if + not ok + or type(data) ~= "table" + or data.version ~= VERSION + or type(data.fetched_at) ~= "number" + or type(data.items) ~= "table" + or not vim.islist(data.items) + then + log.fmt_debug("Ignoring the cache file %s", path) + return nil + end + return data +end + +---Write the file through a temporary file and a rename, so that a reader never gets a part of it. Between two Neovim +---processes, the last writer wins. +---@param path string +---@param dir string +---@param data table +local function write(path, dir, data) + -- `mkdir` gives the mode to each directory that it makes + if vim.fn.isdirectory(dir) == 0 then + pcall(vim.fn.mkdir, dir, "p", PRIVATE_DIR) + end + + local tmp = string.format("%s.tmp.%d", path, vim.uv.os_getpid()) + local fd, open_err = vim.uv.fs_open(tmp, "w", PRIVATE_FILE) + if fd == nil then + log.fmt_debug("Cannot write the cache file %s: %s", tmp, open_err) + return + end + local written, write_err = vim.uv.fs_write(fd, vim.json.encode(data)) + vim.uv.fs_close(fd) + + local renamed, rename_err = nil, nil + if written ~= nil then + renamed, rename_err = vim.uv.fs_rename(tmp, path) + end + if not renamed then + log.fmt_debug("Cannot write the cache file %s: %s", path, write_err or rename_err) + vim.uv.fs_unlink(tmp) + end +end + +---@param path string +local function remove(path) + local ok, err = pcall(vim.fs.rm, path, { recursive = true, force = true }) + if not ok then + log.fmt_debug("Cannot remove %s: %s", path, err) + end +end + +---The entry of a list of a remote. The same arguments give the same entry until a clear. +---@param git_info cmp_git.GitInfo The remote. It needs `host`, `owner`, and `repo`. +---@param kind string The list, such as `github-issues`. +---@param config table The capability config. Its query fields are part of the key. +---@return cmp_git.CacheEntry +function M.entry(git_info, kind, config) + local dir = remote_dir(git_info) + local path = vim.fs.joinpath(dir, string.format("%s-%s.json", kind, query_hash(config))) + local entry = entries[path] + if entry == nil then + entry = setmetatable({ path = path, dir = dir, loaded = false, refreshing = false, dropped = false }, Entry) + entries[path] = entry + end + return entry +end + +---The raw items, or nil on a miss. The first call reads the file. +---@return table[]? +function Entry:get() + if not self.loaded then + self.loaded = true + local data = read(self.path) + if data ~= nil then + self.items = data.items + self.fetched_at = data.fetched_at + end + end + return self.items +end + +---Keep the items in memory and in the file. A dropped entry keeps nothing, so that a fetch that started before a clear +---does not bring the items back. +---@param items table[] +function Entry:set(items) + if self.dropped then + return + end + + self.loaded = true + self.items = items + self.fetched_at = os.time() + self.failed_at = nil + write(self.path, self.dir, { version = VERSION, fetched_at = self.fetched_at, items = items }) +end + +---True when the items are older than `ttl` seconds, and no refresh failed in the last `ttl` seconds. +---@param ttl number +---@return boolean +function Entry:is_stale(ttl) + if self.items == nil or self.fetched_at == nil then + return false + end + + local now = os.time() + return now - self.fetched_at >= ttl and (self.failed_at == nil or now - self.failed_at >= ttl) +end + +---Start a background refresh when the items are stale and no refresh runs. A failure keeps the items. +---@param ttl number +---@param start fun(done: fun(items: table[]?)) Fetches the items. It calls `done` once with them, or with nil when the +---fetch fails. +function Entry:refresh(ttl, start) + if self.refreshing or self.dropped or not self:is_stale(ttl) then + return + end + + self.refreshing = true + start(function(items) + self.refreshing = false + if items ~= nil then + self:set(items) + else + self.failed_at = os.time() + end + end) +end + +---Remove the entries and the files of the remote. +---@param git_info cmp_git.GitInfo +function M.clear_remote(git_info) + local dir = remote_dir(git_info) + for path, entry in pairs(entries) do + if entry.dir == dir then + entry.dropped = true + entries[path] = nil + end + end + remove(dir) +end + +---Remove all entries and the cache directory. +function M.clear_all() + M.forget() + remove(M.root()) +end + +---Remove the entries from memory and keep the files. A fetch that runs keeps nothing, and the next request reads the +---files again, as in a new session. +function M.forget() + for _, entry in pairs(entries) do + entry.dropped = true + end + entries = {} +end + +return M diff --git a/lua/cmp_git/commands.lua b/lua/cmp_git/commands.lua new file mode 100644 index 0000000..a703c4d --- /dev/null +++ b/lua/cmp_git/commands.lua @@ -0,0 +1,82 @@ +-- The code of the user commands that `plugin/cmp_git.lua` defines +local M = {} + +---@param message string +local function notify(message) + vim.notify(message, vim.log.levels.INFO, { title = "cmp-git" }) +end + +---Drop the cached commits and trailers of the buffer, or of all buffers. A source module that is not loaded has no +---caches. +---@param bufnr? integer +local function clear_git_caches(bufnr) + local Git = package.loaded["cmp_git.sources.git"] + if Git then + Git.clear_caches(bufnr) + end +end + +---The remotes of the buffer, without repeats. Discovery runs with the config of each core, because the configs can +---give other hosts for one remote URL, for example with `ssh_aliases`. Without a core, it uses the default config. +---@param bufnr integer +---@param on_complete fun(remotes: cmp_git.GitInfo[]) +local function discover_remotes(bufnr, on_complete) + local Core = require("cmp_git.core") + local cores = Core.instances() + if #cores == 0 then + cores = { Core.new({}) } + end + + local remotes = {} + local found = {} + local pending = #cores + for _, core in ipairs(cores) do + core:discover_remote(bufnr, function(git_info) + if git_info.host ~= nil and git_info.owner ~= nil and git_info.repo ~= nil then + local name = string.format("%s/%s/%s", git_info.host, git_info.owner, git_info.repo) + if not found[name] then + found[name] = true + table.insert(remotes, git_info) + end + end + + pending = pending - 1 + if pending == 0 then + on_complete(remotes) + end + end) + end +end + +---Clear the cache of the remote of the buffer, or of all remotes. Both forms also drop the cached commits and +---trailers, of the buffer or of all buffers. +---@param all boolean +---@param bufnr? integer The buffer, by default the current one. +function M.clear_cache(all, bufnr) + local cache = require("cmp_git.cache") + if all then + cache.clear_all() + clear_git_caches(nil) + notify("Cleared the cache of all remotes.") + return + end + + bufnr = bufnr or vim.api.nvim_get_current_buf() + clear_git_caches(bufnr) + discover_remotes(bufnr, function(remotes) + if #remotes == 0 then + notify("Cleared the commits and trailers of the buffer. The buffer has no remote.") + return + end + + local names = {} + for _, git_info in ipairs(remotes) do + cache.clear_remote(git_info) + table.insert(names, string.format("%s/%s/%s", git_info.host, git_info.owner, git_info.repo)) + end + table.sort(names) + notify(string.format("Cleared the cache of %s.", table.concat(names, ", "))) + end) +end + +return M diff --git a/lua/cmp_git/config.lua b/lua/cmp_git/config.lua index 9706077..ab20ad2 100644 --- a/lua/cmp_git/config.lua +++ b/lua/cmp_git/config.lua @@ -52,6 +52,14 @@ local defaults = { enableRemoteUrlRewrites = false, -- enable git url rewrites, see https://git-scm.com/docs/git-config#Documentation/git-config.txt-urlltbasegtinsteadOf ---@type table ssh_aliases = {}, + ---The disk cache of the GitHub and GitLab items + ---@class cmp_git.Config.Cache + cache = { + ---Seconds after which a request also refreshes the items in the background. 0 refreshes after each request, + ---and math.huge never refreshes. + ---@type number + ttl = 300, + }, ---@class cmp_git.Config.Git ---@field filter_fn? fun(trigger_char: string, item: cmp_git.Commit): string Compatibility alias for the commits format.filterText function. It does not apply to trailers. git = { diff --git a/lua/cmp_git/core.lua b/lua/cmp_git/core.lua index ce8840b..6f1513f 100644 --- a/lua/cmp_git/core.lua +++ b/lua/cmp_git/core.lua @@ -215,7 +215,7 @@ local function lazy_sources(core_config) return nil end - local source = require(module).new(core_config[name]) + local source = require(module).new(core_config[name], core_config.cache) rawset(sources, name, source) return source end, @@ -224,10 +224,14 @@ end local Core = {} +---The cores that are alive. `:CmpGitClearCache` discovers the remote with their configs. +local instances = setmetatable({}, { __mode = "k" }) + function Core.new(overrides) local self = setmetatable({}, { __index = Core, }) + instances[self] = true self.config = config.normalize(overrides) self.hosts = vim.list_extend(vim.list_extend({}, self.config.github.hosts), self.config.gitlab.hosts) @@ -280,6 +284,24 @@ function Core.new(overrides) return self end +---@return table[] cores The cores that are alive, in no order. +function Core.instances() + return vim.tbl_keys(instances) +end + +---Find the remote of the buffer with the remote options of the config. +---@param bufnr? integer The buffer, by default the current one. +---@param on_complete fun(git_info: cmp_git.GitInfo) +function Core:discover_remote(bufnr, on_complete) + require("cmp_git.repository").discover(self.config.remotes, { + enableRemoteUrlRewrites = self.config.enableRemoteUrlRewrites, + ssh_aliases = self.config.ssh_aliases, + hosts = self.hosts, + on_complete = on_complete, + bufnr = bufnr, + }) +end + ---The trigger character that starts the last word of the text before the cursor. A request that the user did not ---start with a trigger character, such as one for the next character after `#`, has none. ---@param line string The text before the cursor. @@ -314,7 +336,7 @@ local function find_trigger_start(line, trigger_character, cursor_character) return trigger_start or cursor_character end ----Copy the items, and give each copy a text edit that replaces the typed text. The capabilities give the same cached +---Copy the items, and give each copy a text edit that replaces the typed text. A legacy `action` can give the same ---items to each request, and blink.cmp writes into the items that it gets (`pos` and `score_offset`), so each request ---needs its own tables. ---@param items? cmp_git.CompletionItem[] @@ -471,13 +493,7 @@ function Core:_complete(context, callback, routes) return controller end - require("cmp_git.repository").discover(self.config.remotes, { - enableRemoteUrlRewrites = self.config.enableRemoteUrlRewrites, - ssh_aliases = self.config.ssh_aliases, - hosts = self.hosts, - on_complete = run, - bufnr = context.bufnr, - }) + self:discover_remote(context.bufnr, run) return controller end diff --git a/lua/cmp_git/format.lua b/lua/cmp_git/format.lua index 07ba26f..55b6947 100644 --- a/lua/cmp_git/format.lua +++ b/lua/cmp_git/format.lua @@ -260,4 +260,17 @@ function M.item(config, trigger_char, item) } end +---@generic TItem +---@param config { format: cmp_git.FormatConfig, sort_by: string | fun(item: TItem): string } +---@param trigger_char string? nil for pattern trigger actions without a trigger character +---@param items TItem[] +---@return cmp_git.CompletionItem[] +function M.items(config, trigger_char, items) + local result = {} + for index, item in ipairs(items) do + result[index] = M.item(config, trigger_char, item) + end + return result +end + return M diff --git a/lua/cmp_git/provider/capabilities/change_requests.lua b/lua/cmp_git/provider/capabilities/change_requests.lua index b434c8f..a2d0578 100644 --- a/lua/cmp_git/provider/capabilities/change_requests.lua +++ b/lua/cmp_git/provider/capabilities/change_requests.lua @@ -5,10 +5,7 @@ local M = {} ---@param args cmp_git.Provider.CapabilityArgs ---@return cmp_git.SystemJob? function M.job(args) - local bufnr = args.bufnr or vim.api.nvim_get_current_buf() - - if args.cache[bufnr] then - args.callback({ items = args.cache[bufnr], isIncomplete = false }) + if issues.from_cache(args) then return nil end @@ -24,4 +21,10 @@ function M.complete(args) return job end +---@param args cmp_git.Provider.RefreshArgs +---@param done fun(items: table[]?) +function M.refresh(args, done) + issues.fetch(args.runner, args.adapter.change_requests_request(args.git_info, args.trigger_char, args.config), done) +end + return M diff --git a/lua/cmp_git/provider/capabilities/issues.lua b/lua/cmp_git/provider/capabilities/issues.lua index a015e9f..45e6e0a 100644 --- a/lua/cmp_git/provider/capabilities/issues.lua +++ b/lua/cmp_git/provider/capabilities/issues.lua @@ -1,4 +1,5 @@ local command = require("cmp_git.command") +local format = require("cmp_git.format") local M = {} @@ -7,15 +8,46 @@ local M = {} ---@field handle_item fun(item: any): cmp_git.CompletionItem ---@field handle_parsed? fun(parsed: any): any[]? A result that is not a list is a failure. ----@class cmp_git.Provider.CapabilityArgs +---The arguments of a refresh. A refresh only fills the cache, so it has no callback. +---@class cmp_git.Provider.RefreshArgs ---@field adapter table ----@field cache table ----@field callback fun(list: cmp_git.CompletionList) ---@field config table ---@field git_info cmp_git.GitInfo ---@field trigger_char string ---@field runner? table ----@field bufnr? integer + +---@class cmp_git.Provider.CapabilityArgs: cmp_git.Provider.RefreshArgs +---@field cache cmp_git.CacheEntry +---@field callback fun(list: cmp_git.CompletionList) + +---A capability module, such as this one. +---@class cmp_git.Provider.Capability +---@field complete fun(args: cmp_git.Provider.CapabilityArgs): table? +---@field refresh fun(args: cmp_git.Provider.RefreshArgs, done: fun(items: table[]?)) + +---The raw items of the completion items. `format.item` keeps the raw item in `data`. +---@param items cmp_git.CompletionItem[] +---@return table[] +function M.raw_items(items) + local raw = {} + for index, item in ipairs(items) do + raw[index] = item.data + end + return raw +end + +---Give the cached items to the callback, formatted for this request. +---@param args cmp_git.Provider.CapabilityArgs +---@return boolean hit +function M.from_cache(args) + local items = args.cache:get() + if items == nil then + return false + end + + args.callback({ items = format.items(args.config, args.trigger_char, items), isIncomplete = false }) + return true +end ---Make the job of a list request. The callback gets exactly one list, also when the commands fail or cannot run. A ---failure gives an empty list and is not cached, so that the next request tries again. @@ -23,7 +55,6 @@ local M = {} ---@param request cmp_git.Provider.ListRequest ---@return cmp_git.SystemJob? job nil when the callback already has its list. function M.list_job(args, request) - local bufnr = args.bufnr or vim.api.nvim_get_current_buf() local runner = args.runner or command local job = runner.build_fallback_list(request.commands, function(list, success) if not success then @@ -31,7 +62,7 @@ function M.list_job(args, request) return end - args.cache[bufnr] = list.items + args.cache:set(M.raw_items(list.items)) args.callback(list) end, request.handle_item, request.handle_parsed) @@ -41,13 +72,26 @@ function M.list_job(args, request) return job end +---Start a list request for the cache only. +---@param runner? table +---@param request cmp_git.Provider.ListRequest +---@param done fun(items: table[]?) Gets the raw items, or nil when the request fails or cannot run. +function M.fetch(runner, request, done) + local job = (runner or command).build_fallback_list(request.commands, function(list, success) + done(success and M.raw_items(list.items) or nil) + end, request.handle_item, request.handle_parsed) + + if not job then + done(nil) + return + end + job:start() +end + ---@param args cmp_git.Provider.CapabilityArgs ---@return cmp_git.SystemJob? function M.job(args) - local bufnr = args.bufnr or vim.api.nvim_get_current_buf() - - if args.cache[bufnr] then - args.callback({ items = args.cache[bufnr], isIncomplete = false }) + if M.from_cache(args) then return nil end @@ -63,4 +107,10 @@ function M.complete(args) return job end +---@param args cmp_git.Provider.RefreshArgs +---@param done fun(items: table[]?) +function M.refresh(args, done) + M.fetch(args.runner, args.adapter.issues_request(args.git_info, args.trigger_char, args.config), done) +end + return M diff --git a/lua/cmp_git/provider/capabilities/mentions.lua b/lua/cmp_git/provider/capabilities/mentions.lua index 12519d7..c56f388 100644 --- a/lua/cmp_git/provider/capabilities/mentions.lua +++ b/lua/cmp_git/provider/capabilities/mentions.lua @@ -1,42 +1,45 @@ local command = require("cmp_git.command") +local format = require("cmp_git.format") +local issues = require("cmp_git.provider.capabilities.issues") local M = {} ---The most users that the REST APIs give on one page. local MAX_PAGE_SIZE = 100 ----The mentions of a buffer. While `in_progress`, `items` has the pages so far and `fetch` gets more. ----@class cmp_git.AsyncItemList ----@field in_progress boolean ----@field items cmp_git.CompletionItem[] ----@field fetch? cmp_git.Provider.MentionFetch - ----The requests that wait for the pages of a buffer. They share one fetch. +---A fetch of the pages of mentions. The requests that wait for it share it. ---@class cmp_git.Provider.MentionFetch ----@field subscribers { callback: fun(list: cmp_git.CompletionList) }[] +---@field items table[] The raw users of the pages so far. +---@field subscribers cmp_git.Provider.MentionSubscriber[] ---@field active? cmp_git.SystemJob ---@field cancelled boolean - ----@class cmp_git.Provider.MentionCapabilityArgs: cmp_git.Provider.CapabilityArgs ----@field cache table - ----@param subscribers { callback: fun(list: cmp_git.CompletionList) }[] ----@param list cmp_git.CompletionList -local function notify(subscribers, list) +---@field done boolean + +---A request that waits for the pages of a fetch. Each request formats the users with its own config. +---@class cmp_git.Provider.MentionSubscriber +---@field callback fun(list: cmp_git.CompletionList) +---@field config table +---@field trigger_char string + +---@param subscribers cmp_git.Provider.MentionSubscriber[] +---@param items table[] The raw users. +---@param incomplete boolean +local function notify(subscribers, items, incomplete) -- A copy, because a callback can cancel its request for _, subscriber in ipairs(vim.list_extend({}, subscribers)) do - subscriber.callback(list) + subscriber.callback({ + items = format.items(subscriber.config, subscriber.trigger_char, items), + isIncomplete = incomplete, + }) end end ----Add a request to the fetch of the entry. The fetch stops when all its requests are cancelled. ----@param cache table ----@param bufnr integer ----@param entry cmp_git.AsyncItemList ----@param fetch cmp_git.Provider.MentionFetch The fetch of the entry. ----@param callback fun(list: cmp_git.CompletionList) -local function subscribe(cache, bufnr, entry, fetch, callback) - local subscriber = { callback = callback } +---Add a request to a fetch of the entry. The fetch stops when all its requests are cancelled. +---@param entry cmp_git.CacheEntry +---@param fetch cmp_git.Provider.MentionFetch +---@param args cmp_git.Provider.CapabilityArgs +local function subscribe(entry, fetch, args) + local subscriber = { callback = args.callback, config = args.config, trigger_char = args.trigger_char } table.insert(fetch.subscribers, subscriber) return { @@ -48,14 +51,14 @@ local function subscribe(cache, bufnr, entry, fetch, callback) break end end - if #fetch.subscribers > 0 or fetch.cancelled or not entry.in_progress then + if #fetch.subscribers > 0 or fetch.cancelled or fetch.done then return end - -- Otherwise the entry stays in progress, and later requests wait for pages that never come + -- Otherwise the entry keeps the fetch, and later requests wait for pages that never come fetch.cancelled = true - if cache[bufnr] == entry then - cache[bufnr] = nil + if entry.fetch == fetch then + entry.fetch = nil end if fetch.active and fetch.active.cancel then fetch.active:cancel() @@ -64,11 +67,12 @@ local function subscribe(cache, bufnr, entry, fetch, callback) } end ----@param args cmp_git.Provider.MentionCapabilityArgs ----@param bufnr integer ----@param entry cmp_git.AsyncItemList ----@param fetch cmp_git.Provider.MentionFetch The fetch of the entry. -local function start_fetch(args, bufnr, entry, fetch) +---Get the pages of users into `fetch.items`. +---@param args cmp_git.Provider.RefreshArgs +---@param fetch cmp_git.Provider.MentionFetch +---@param on_page fun() Runs after each page before the last. +---@param on_done fun(success: boolean) Runs once after the last page or a failure, unless the fetch is cancelled. +local function start_fetch(args, fetch, on_page, on_done) local runner = args.runner or command local limit = args.config.limit -- A constant page size, so that page N starts after the users of the pages before it @@ -76,15 +80,9 @@ local function start_fetch(args, bufnr, entry, fetch) ---@param success boolean local function finish(success) - entry.in_progress = false - entry.fetch = nil - -- A failure is not cached, so that the next request tries again. The pages before it are still shown. - if not success and args.cache[bufnr] == entry then - args.cache[bufnr] = nil - end - local subscribers = fetch.subscribers - fetch.subscribers = {} - notify(subscribers, { items = entry.items, isIncomplete = false }) + fetch.done = true + fetch.active = nil + on_done(success) end if limit < 1 then @@ -108,7 +106,7 @@ local function start_fetch(args, bufnr, entry, fetch) return end - local items = vim.list_extend(vim.list_extend({}, entry.items), list.items) + local items = vim.list_extend(vim.list_extend({}, fetch.items), issues.raw_items(list.items)) local has_more if args.adapter.mentions_has_more then has_more = args.adapter.mentions_has_more(list, items, args.config, context) @@ -122,14 +120,14 @@ local function start_fetch(args, bufnr, entry, fetch) items[i] = nil end end - entry.items = items + fetch.items = items if not has_more then finish(true) return end - notify(fetch.subscribers, { items = items, isIncomplete = true }) + on_page() fetch_page(page + 1, context) end, request.handle_item, request.handle_parsed) @@ -169,32 +167,58 @@ local function start_fetch(args, bufnr, entry, fetch) job:start() end ----Give the mentions of the buffer. The callback gets a list with `isIncomplete = true` for each page before the last, ----then exactly one list with `isIncomplete = false`, unless the request is cancelled. ----@param args cmp_git.Provider.MentionCapabilityArgs +---@return cmp_git.Provider.MentionFetch +local function new_fetch() + return { items = {}, subscribers = {}, cancelled = false, done = false } +end + +---Give the mentions of the remote. The callback gets a list with `isIncomplete = true` for each page before the last, +---then exactly one list with `isIncomplete = false`, unless the request is cancelled. A request during a fetch joins +---it. +---@param args cmp_git.Provider.CapabilityArgs ---@return { command: string, cancel: fun() }? controller nil when the callback already has its final list. function M.complete(args) - local bufnr = args.bufnr or vim.api.nvim_get_current_buf() - local entry = args.cache[bufnr] - - if entry and not entry.in_progress then - args.callback({ items = entry.items, isIncomplete = false }) + if issues.from_cache(args) then return nil end - if entry then - if #entry.items > 0 then - args.callback({ items = entry.items, isIncomplete = true }) + local entry = args.cache + local fetch = entry.fetch + if fetch then + if #fetch.items > 0 then + args.callback({ items = format.items(args.config, args.trigger_char, fetch.items), isIncomplete = true }) end - return subscribe(args.cache, bufnr, entry, entry.fetch --[[@as cmp_git.Provider.MentionFetch]], args.callback) + return subscribe(entry, fetch, args) end - local fetch = { subscribers = {}, cancelled = false } - entry = { items = {}, in_progress = true, fetch = fetch } - args.cache[bufnr] = entry - local controller = subscribe(args.cache, bufnr, entry, fetch, args.callback) - start_fetch(args, bufnr, entry, fetch) + fetch = new_fetch() + entry.fetch = fetch + local controller = subscribe(entry, fetch, args) + start_fetch(args, fetch, function() + notify(fetch.subscribers, fetch.items, true) + end, function(success) + if entry.fetch == fetch then + entry.fetch = nil + end + -- A failure is not cached, so that the next request tries again. The pages before it are still shown. + if success then + entry:set(fetch.items) + end + local subscribers = fetch.subscribers + fetch.subscribers = {} + notify(subscribers, fetch.items, false) + end) return controller end +---Get all pages for the cache only. +---@param args cmp_git.Provider.RefreshArgs +---@param done fun(items: table[]?) Gets the raw users, or nil when a page fails. +function M.refresh(args, done) + local fetch = new_fetch() + start_fetch(args, fetch, function() end, function(success) + done(success and fetch.items or nil) + end) +end + return M diff --git a/lua/cmp_git/sources/git.lua b/lua/cmp_git/sources/git.lua index 6ac3c86..1ef0e10 100644 --- a/lua/cmp_git/sources/git.lua +++ b/lua/cmp_git/sources/git.lua @@ -9,11 +9,18 @@ local trailers = require("cmp_git.provider.capabilities.trailers") ---@field cache_trailer_values table> local Git = {} +-- The fields of the caches, which are keyed by buffer number +local CACHE_FIELDS = { "cache_commits", "cache_trailer_keys", "cache_trailer_identities", "cache_trailer_values" } + +---The sources that are alive. `:CmpGitClearCache` drops their caches. +local instances = setmetatable({}, { __mode = "k" }) + ---@param overrides cmp_git.Config.Git function Git.new(overrides) local self = setmetatable({}, { __index = Git, }) + instances[self] = true self.cache_commits = {} self.cache_trailer_keys = {} @@ -25,6 +32,21 @@ function Git.new(overrides) return self end +---Drop the cached commits and trailers of the buffer in all sources, or of all buffers. +---@param bufnr? integer +function Git.clear_caches(bufnr) + for source in pairs(instances) do + for _, field in ipairs(CACHE_FIELDS) do + if bufnr then + source[field][bufnr] = nil + else + -- A new table, so that a git command that ends after the clear fills the old one + source[field] = {} + end + end + end +end + ---@param callback fun(commits: cmp_git.CompletionList) ---@param trigger_char string function Git:get_commits(callback, context, trigger_char) diff --git a/lua/cmp_git/sources/github.lua b/lua/cmp_git/sources/github.lua index 8f65917..efd1ef8 100644 --- a/lua/cmp_git/sources/github.lua +++ b/lua/cmp_git/sources/github.lua @@ -1,16 +1,11 @@ local adapter = require("cmp_git.provider.adapters.github") +local cache = require("cmp_git.cache") local command = require("cmp_git.command") local issues = require("cmp_git.provider.capabilities.issues") local mentions = require("cmp_git.provider.capabilities.mentions") local change_requests = require("cmp_git.provider.capabilities.change_requests") local log = require("cmp_git.log") ----@class cmp_git.Source.GitHub.Cache ----@field issues table ----@field mentions table ----@field change_requests table ----@field pull_requests table The `change_requests` table. - ---The result of `gh repo set-default --view` for a repository. Until it is `done`, the requests wait in `waiters`. ---@class cmp_git.Source.GitHub.DefaultRepo ---@field done boolean @@ -20,26 +15,21 @@ local log = require("cmp_git.log") ---@class cmp_git.Source.GitHub ---@field config cmp_git.Config.GitHub ----@field cache cmp_git.Source.GitHub.Cache +---@field cache_config cmp_git.Config.Cache ---@field default_repos table By `host/owner/repo` of the remote. local GitHub = {} ---@param overrides cmp_git.Config.GitHub -function GitHub.new(overrides) +---@param cache_config? cmp_git.Config.Cache +function GitHub.new(overrides, cache_config) local self = setmetatable({}, { __index = GitHub, }) - local change_requests_cache = {} - self.cache = { - issues = {}, - mentions = {}, - change_requests = change_requests_cache, - pull_requests = change_requests_cache, - } self.default_repos = {} self.config = overrides or {} + self.cache_config = cache_config or require("cmp_git.config").cache return self end @@ -141,35 +131,62 @@ function GitHub:_with_repo(git_info, bufnr, cached, start) } end ----@param callback fun(list: cmp_git.CompletionList) +---The cache entry of a list of the remote. The key is the remote that discovery found and not the default repository, +---so that a hit needs no gh. A refresh asks gh again, so the entry follows a change of the default repository. +---@param kind "issues" | "pull_requests" | "mentions" +---@param git_info cmp_git.GitInfo +---@return cmp_git.CacheEntry +function GitHub:_entry(kind, git_info) + return cache.entry(git_info, "github-" .. kind, self.config[kind]) +end + +---Refresh the entry in the background when its items are stale. +---@param entry cmp_git.CacheEntry +---@param capability cmp_git.Provider.Capability +---@param kind "issues" | "pull_requests" | "mentions" ---@param git_info cmp_git.GitInfo ---@param trigger_char string ---@param bufnr integer -function GitHub:_issues_job(callback, git_info, trigger_char, bufnr) +function GitHub:_refresh(entry, capability, kind, git_info, trigger_char, bufnr) + entry:refresh(self.cache_config.ttl, function(done) + self:_with_repo(git_info, bufnr, false, function(repo_info) + capability.refresh({ + adapter = adapter, + config = self.config[kind], + git_info = repo_info, + trigger_char = trigger_char, + }, done) + end) + end) +end + +---@param callback fun(list: cmp_git.CompletionList) +---@param git_info cmp_git.GitInfo +---@param trigger_char string +---@param entry cmp_git.CacheEntry +function GitHub:_issues_job(callback, git_info, trigger_char, entry) return issues.job({ adapter = adapter, - cache = self.cache.issues, + cache = entry, callback = callback, config = self.config.issues, git_info = git_info, trigger_char = trigger_char, - bufnr = bufnr, }) end ---@param callback fun(list: cmp_git.CompletionList) ---@param git_info cmp_git.GitInfo ---@param trigger_char string ----@param bufnr integer -function GitHub:_change_requests_job(callback, git_info, trigger_char, bufnr) +---@param entry cmp_git.CacheEntry +function GitHub:_change_requests_job(callback, git_info, trigger_char, entry) return change_requests.job({ adapter = adapter, - cache = self.cache.change_requests, + cache = entry, callback = callback, config = self.config.pull_requests, git_info = git_info, trigger_char = trigger_char, - bufnr = bufnr, }) end @@ -184,14 +201,16 @@ function GitHub:complete_issues(callback, git_info, trigger_char, bufnr) -- The buffer of the request, also when the lookup of the default repository ends in another buffer bufnr = bufnr or vim.api.nvim_get_current_buf() - return true, - self:_with_repo(git_info, bufnr, self.cache.issues[bufnr] ~= nil, function(repo_info) - local job = self:_issues_job(callback, repo_info, trigger_char, bufnr) - if job then - job:start() - end - return job - end) + local entry = self:_entry("issues", git_info) + local job = self:_with_repo(git_info, bufnr, entry:get() ~= nil, function(repo_info) + local request = self:_issues_job(callback, repo_info, trigger_char, entry) + if request then + request:start() + end + return request + end) + self:_refresh(entry, issues, "issues", git_info, trigger_char, bufnr) + return true, job end ---@param callback fun(list: cmp_git.CompletionList) @@ -204,21 +223,24 @@ function GitHub:complete_change_requests(callback, git_info, trigger_char, bufnr end bufnr = bufnr or vim.api.nvim_get_current_buf() - return true, - self:_with_repo(git_info, bufnr, self.cache.change_requests[bufnr] ~= nil, function(repo_info) - local job = self:_change_requests_job(callback, repo_info, trigger_char, bufnr) - if job then - job:start() - end - return job - end) + local entry = self:_entry("pull_requests", git_info) + local job = self:_with_repo(git_info, bufnr, entry:get() ~= nil, function(repo_info) + local request = self:_change_requests_job(callback, repo_info, trigger_char, entry) + if request then + request:start() + end + return request + end) + self:_refresh(entry, change_requests, "pull_requests", git_info, trigger_char, bufnr) + return true, job end ---@param callback fun(list: cmp_git.CompletionList) ---@param git_info cmp_git.GitInfo ---@param trigger_char string ----@param bufnr integer -function GitHub:_issues_and_change_requests(callback, git_info, trigger_char, bufnr) +---@param issues_entry cmp_git.CacheEntry +---@param change_requests_entry cmp_git.CacheEntry +function GitHub:_issues_and_change_requests(callback, git_info, trigger_char, issues_entry, change_requests_entry) local results = {} local pending = 2 local cancelled = false @@ -237,15 +259,16 @@ function GitHub:_issues_and_change_requests(callback, git_info, trigger_char, bu return end - -- A new list, so that the issue cache does not get the pull requests - local items = vim.list_extend(vim.list_extend({}, results.issues), results.change_requests) + -- Each request gets new lists, because the cache keeps the raw items + local items = vim.list_extend(results.issues, results.change_requests) log.fmt_debug("Got %d issues and prs", #items) callback({ items = items, isIncomplete = false }) end end - local issues_job = self:_issues_job(collect("issues"), git_info, trigger_char, bufnr) - local change_requests_job = self:_change_requests_job(collect("change_requests"), git_info, trigger_char, bufnr) + local issues_job = self:_issues_job(collect("issues"), git_info, trigger_char, issues_entry) + local change_requests_job = + self:_change_requests_job(collect("change_requests"), git_info, trigger_char, change_requests_entry) -- Both commands run at once, so that a cold request waits for the slower one, not for both in turn if issues_job then issues_job:start() @@ -277,11 +300,15 @@ function GitHub:complete_issues_and_change_requests(callback, git_info, trigger_ end bufnr = bufnr or vim.api.nvim_get_current_buf() - local cached = self.cache.issues[bufnr] ~= nil and self.cache.change_requests[bufnr] ~= nil - return true, - self:_with_repo(git_info, bufnr, cached, function(repo_info) - return self:_issues_and_change_requests(callback, repo_info, trigger_char, bufnr) - end) + local issues_entry = self:_entry("issues", git_info) + local change_requests_entry = self:_entry("pull_requests", git_info) + local cached = issues_entry:get() ~= nil and change_requests_entry:get() ~= nil + local job = self:_with_repo(git_info, bufnr, cached, function(repo_info) + return self:_issues_and_change_requests(callback, repo_info, trigger_char, issues_entry, change_requests_entry) + end) + self:_refresh(issues_entry, issues, "issues", git_info, trigger_char, bufnr) + self:_refresh(change_requests_entry, change_requests, "pull_requests", git_info, trigger_char, bufnr) + return true, job end ---@param callback fun(list: cmp_git.CompletionList) @@ -293,20 +320,21 @@ function GitHub:complete_mentions(callback, git_info, trigger_char, bufnr) return false end - -- A request during a fetch joins it, so it needs no repository either bufnr = bufnr or vim.api.nvim_get_current_buf() - return true, - self:_with_repo(git_info, bufnr, self.cache.mentions[bufnr] ~= nil, function(repo_info) - return mentions.complete({ - adapter = adapter, - cache = self.cache.mentions, - callback = callback, - config = self.config.mentions, - git_info = repo_info, - trigger_char = trigger_char, - bufnr = bufnr, - }) - end) + local entry = self:_entry("mentions", git_info) + -- A request during a fetch joins it, so it needs no repository either + local job = self:_with_repo(git_info, bufnr, entry:get() ~= nil or entry.fetch ~= nil, function(repo_info) + return mentions.complete({ + adapter = adapter, + cache = entry, + callback = callback, + config = self.config.mentions, + git_info = repo_info, + trigger_char = trigger_char, + }) + end) + self:_refresh(entry, mentions, "mentions", git_info, trigger_char, bufnr) + return true, job end function GitHub:get_issues(callback, git_info, trigger_char) diff --git a/lua/cmp_git/sources/gitlab.lua b/lua/cmp_git/sources/gitlab.lua index a41bc32..4cd1647 100644 --- a/lua/cmp_git/sources/gitlab.lua +++ b/lua/cmp_git/sources/gitlab.lua @@ -1,34 +1,23 @@ local adapter = require("cmp_git.provider.adapters.gitlab") +local cache = require("cmp_git.cache") local issues = require("cmp_git.provider.capabilities.issues") local mentions = require("cmp_git.provider.capabilities.mentions") local change_requests = require("cmp_git.provider.capabilities.change_requests") ----@class cmp_git.Source.Gitlab.Cache ----@field issues table ----@field mentions table ----@field change_requests table ----@field merge_requests table The `change_requests` table. - ---@class cmp_git.Source.Gitlab ---@field config cmp_git.Config.Gitlab ----@field cache cmp_git.Source.Gitlab.Cache +---@field cache_config cmp_git.Config.Cache local GitLab = {} ---@param overrides cmp_git.Config.Gitlab -function GitLab.new(overrides) +---@param cache_config? cmp_git.Config.Cache +function GitLab.new(overrides, cache_config) local self = setmetatable({}, { __index = GitLab, }) - local change_requests_cache = {} - self.cache = { - issues = {}, - mentions = {}, - change_requests = change_requests_cache, - merge_requests = change_requests_cache, - } - self.config = overrides or {} + self.cache_config = cache_config or require("cmp_git.config").cache return self end @@ -46,70 +35,74 @@ function GitLab:is_valid_host(git_info) return true end +---@param kind "issues" | "merge_requests" | "mentions" +---@param git_info cmp_git.GitInfo +---@return cmp_git.CacheEntry +function GitLab:_entry(kind, git_info) + return cache.entry(git_info, "gitlab-" .. kind, self.config[kind]) +end + +---Start the request of a capability, then refresh its entry in the background when the items are stale. +---@param capability cmp_git.Provider.Capability +---@param kind "issues" | "merge_requests" | "mentions" ---@param callback fun(list: cmp_git.CompletionList) ---@param git_info cmp_git.GitInfo ---@param trigger_char string ----@param bufnr? integer The buffer of the request, by default the current one. -function GitLab:complete_issues(callback, git_info, trigger_char, bufnr) - if not self:is_valid_host(git_info) then - return false - end - - local job = issues.complete({ +---@return table? job +function GitLab:_complete(capability, kind, callback, git_info, trigger_char) + local entry = self:_entry(kind, git_info) + local job = capability.complete({ adapter = adapter, - cache = self.cache.issues, + cache = entry, callback = callback, - config = self.config.issues, + config = self.config[kind], git_info = git_info, trigger_char = trigger_char, - bufnr = bufnr, }) - return true, job + entry:refresh(self.cache_config.ttl, function(done) + capability.refresh({ + adapter = adapter, + config = self.config[kind], + git_info = git_info, + trigger_char = trigger_char, + }, done) + end) + + return job end ---@param callback fun(list: cmp_git.CompletionList) ---@param git_info cmp_git.GitInfo ---@param trigger_char string ----@param bufnr? integer The buffer of the request, by default the current one. -function GitLab:complete_mentions(callback, git_info, trigger_char, bufnr) +function GitLab:complete_issues(callback, git_info, trigger_char) if not self:is_valid_host(git_info) then return false end - local job = mentions.complete({ - adapter = adapter, - cache = self.cache.mentions, - callback = callback, - config = self.config.mentions, - git_info = git_info, - trigger_char = trigger_char, - bufnr = bufnr, - }) - - return true, job + return true, self:_complete(issues, "issues", callback, git_info, trigger_char) end ---@param callback fun(list: cmp_git.CompletionList) ---@param git_info cmp_git.GitInfo ---@param trigger_char string ----@param bufnr? integer The buffer of the request, by default the current one. -function GitLab:complete_change_requests(callback, git_info, trigger_char, bufnr) +function GitLab:complete_mentions(callback, git_info, trigger_char) if not self:is_valid_host(git_info) then return false end - local job = change_requests.complete({ - adapter = adapter, - cache = self.cache.change_requests, - callback = callback, - config = self.config.merge_requests, - git_info = git_info, - trigger_char = trigger_char, - bufnr = bufnr, - }) + return true, self:_complete(mentions, "mentions", callback, git_info, trigger_char) +end + +---@param callback fun(list: cmp_git.CompletionList) +---@param git_info cmp_git.GitInfo +---@param trigger_char string +function GitLab:complete_change_requests(callback, git_info, trigger_char) + if not self:is_valid_host(git_info) then + return false + end - return true, job + return true, self:_complete(change_requests, "merge_requests", callback, git_info, trigger_char) end function GitLab:get_issues(callback, git_info, trigger_char) diff --git a/plugin/cmp_git.lua b/plugin/cmp_git.lua new file mode 100644 index 0000000..9cd2de9 --- /dev/null +++ b/plugin/cmp_git.lua @@ -0,0 +1,12 @@ +if vim.g.loaded_cmp_git then + return +end +vim.g.loaded_cmp_git = true + +-- The command loads its module when it runs, so that startup loads no cmp-git module +vim.api.nvim_create_user_command("CmpGitClearCache", function(opts) + require("cmp_git.commands").clear_cache(opts.bang) +end, { + bang = true, + desc = "Clear the cmp-git cache of the remote of the current buffer, or with ! of all remotes", +}) diff --git a/tests/helpers.lua b/tests/helpers.lua index 009d68d..4635404 100644 --- a/tests/helpers.lua +++ b/tests/helpers.lua @@ -104,6 +104,7 @@ local ENV_NAMES = { "GIT_INDEX_FILE", "GIT_WORK_TREE", "HOME", + "XDG_CACHE_HOME", "XDG_CONFIG_HOME", } @@ -185,6 +186,8 @@ local function setup_case() end vim.fn.mkdir(temp_root .. "/home", "p") vim.env.HOME = temp_root .. "/home" + -- The disk cache of the GitHub and GitLab items goes to `stdpath("cache")` + vim.env.XDG_CACHE_HOME = temp_root .. "/cache" vim.env.GIT_CONFIG_GLOBAL = "/dev/null" vim.env.GIT_CONFIG_NOSYSTEM = "1" -- A temporary directory outside a repository must not resolve to a repository above it @@ -216,6 +219,13 @@ local function teardown_case() case.restores[i]() end + -- The entries are shared by all sources. Dropped entries keep nothing, so a fetch of this case that ends later + -- does not write into the next case. + local cache = package.loaded["cmp_git.cache"] + if cache then + cache.forget() + end + for _, name in ipairs(ENV_NAMES) do -- A nil value unsets the variable vim.env[name] = case.env[name] diff --git a/tests/provider/capabilities/test_change_requests.lua b/tests/provider/capabilities/test_change_requests.lua index 7b640f3..2c5a63b 100644 --- a/tests/provider/capabilities/test_change_requests.lua +++ b/tests/provider/capabilities/test_change_requests.lua @@ -1,4 +1,7 @@ local helpers = require("tests.helpers") +local cache = require("cmp_git.cache") +local config = require("cmp_git.config") +local format = require("cmp_git.format") local change_request_capability = require("cmp_git.provider.capabilities.change_requests") local eq = helpers.eq @@ -6,42 +9,58 @@ local neq = helpers.neq local T = helpers.new_set() -T["change_request_capability_returns_job"] = function() - local runner = { - build_fallback_list = function(_commands, callback, handle_item) - return helpers.fake_job(function() - callback({ items = { handle_item({ name = "pr" }) }, isIncomplete = false }, true) - end) - end, - } - local adapter = { - change_requests_request = function() - return { - commands = { { exec = "fake", args = {} } }, - handle_item = function(item) - return { label = item.name } - end, - } - end, - } +local git_info = { host = "github.com", owner = "owner", repo = "repo" } + +---@return cmp_git.Config.GitHub.PullRequest +local function pr_config() + return config.normalize({}).github.pull_requests +end + +local pr = { number = 2, title = "a pull request", body = "", updatedAt = "2024-01-01T00:00:00Z", state = "open" } - local job = change_request_capability.complete({ +local adapter = { + change_requests_request = function(_git_info, trigger_char, capability_config) + return { + commands = { { exec = "fake", args = {} } }, + handle_item = function(item) + return format.item(capability_config, trigger_char, item) + end, + } + end, +} + +local runner = { + build_fallback_list = function(_commands, callback, handle_item) + return helpers.fake_job(function() + callback({ items = { handle_item(vim.deepcopy(pr)) }, isIncomplete = false }, true) + end) + end, +} + +T["change_request_capability_returns_job"] = function() + local capability_config = pr_config() + local callback, lists = helpers.recorder() + local args = { adapter = adapter, - cache = {}, - callback = function() end, - config = {}, - git_info = {}, + cache = cache.entry(git_info, "github-pull_requests", capability_config), + callback = callback, + config = capability_config, + git_info = git_info, trigger_char = "#", runner = runner, - bufnr = 45, - }) + } + + local job = change_request_capability.complete(args) + local cached_job = change_request_capability.complete(args) neq(job, nil, "change request capability returns started job") + eq(cached_job, nil, "a cache hit returns no job") + eq(lists[2].items[1].label, "#2: a pull request", "a hit formats the cached pull request") end T["failure_gives_an_empty_list_and_is_not_cached"] = function() local runs = 0 - local cache = {} + local capability_config = pr_config() local callback, lists = helpers.recorder() local args = { adapter = { @@ -49,10 +68,10 @@ T["failure_gives_an_empty_list_and_is_not_cached"] = function() return { commands = {}, handle_item = function() end } end, }, - cache = cache, + cache = cache.entry(git_info, "github-pull_requests", capability_config), callback = callback, - config = {}, - git_info = {}, + config = capability_config, + git_info = git_info, trigger_char = "#", runner = { build_fallback_list = function(_commands, list_callback) @@ -65,7 +84,6 @@ T["failure_gives_an_empty_list_and_is_not_cached"] = function() end) end, }, - bufnr = 46, } change_request_capability.complete(args) @@ -75,8 +93,24 @@ T["failure_gives_an_empty_list_and_is_not_cached"] = function() local empty = { items = {}, isIncomplete = false } eq(lists, { empty, empty, empty }, "each request gets one empty list") - eq(cache[46], nil, "a failure is not cached") + eq(args.cache:get(), nil, "a failure is not cached") eq(runs, 2, "the next request tries again") end +T["a_refresh_gives_the_raw_items"] = function() + local results = {} + + change_request_capability.refresh({ + adapter = adapter, + config = pr_config(), + git_info = git_info, + trigger_char = "#", + runner = runner, + }, function(items) + table.insert(results, items) + end) + + eq(results, { { pr } }, "the raw pull requests") +end + return T diff --git a/tests/provider/capabilities/test_issues.lua b/tests/provider/capabilities/test_issues.lua index 41d63bc..ed68e21 100644 --- a/tests/provider/capabilities/test_issues.lua +++ b/tests/provider/capabilities/test_issues.lua @@ -1,4 +1,7 @@ local helpers = require("tests.helpers") +local cache = require("cmp_git.cache") +local config = require("cmp_git.config") +local format = require("cmp_git.format") local issue_capability = require("cmp_git.provider.capabilities.issues") local eq = helpers.eq @@ -6,78 +9,133 @@ local neq = helpers.neq local T = helpers.new_set() -T["issue_capability_uses_cache_and_runner"] = function() - local callback_count = 0 - local last_list - local request_count = 0 - local runner_count = 0 - local adapter = { - issues_request = function() - request_count = request_count + 1 +local git_info = { host = "github.com", owner = "owner", repo = "repo" } + +---@param number integer +---@return cmp_git.GitHub.Issue +local function issue(number) + return { number = number, title = "issue " .. number, body = "", updatedAt = "2024-01-01T00:00:00Z", state = "open" } +end + +---An adapter that formats its items like the real adapters, and counts its requests. +---@return table adapter +---@return { count: integer } requests +local function counting_adapter() + local requests = { count = 0 } + return { + issues_request = function(_git_info, trigger_char, capability_config) + requests.count = requests.count + 1 return { commands = { { exec = "fake", args = { "issues" } } }, handle_item = function(item) - return { label = item.name } + return format.item(capability_config, trigger_char, item) end, } end, - } - local runner = { + }, + requests +end + +---A runner whose jobs give the issues at once. +---@param issues cmp_git.GitHub.Issue[] +---@return table runner +---@return { count: integer } runs +local function answering_runner(issues) + local runs = { count = 0 } + return { build_fallback_list = function(commands, callback, handle_item) - runner_count = runner_count + 1 + runs.count = runs.count + 1 eq(commands[1].exec, "fake", "issue capability forwards commands") return helpers.fake_job(function() - callback({ items = { handle_item({ name = "one" }) }, isIncomplete = false }, true) + callback({ items = vim.tbl_map(handle_item, vim.deepcopy(issues)), isIncomplete = false }, true) end) end, - } - local args = { - adapter = adapter, - cache = {}, - callback = function(list) - callback_count = callback_count + 1 - last_list = list - end, - config = {}, - git_info = {}, + }, + runs +end + +---@param overrides table +---@return cmp_git.Provider.CapabilityArgs args +---@return cmp_git.CompletionList[] lists +local function capability_args(overrides) + local callback, lists = helpers.recorder() + local issues_config = config.normalize({}).github.issues + return vim.tbl_extend("force", { + cache = cache.entry(git_info, "github-issues", issues_config), + callback = callback, + config = issues_config, + git_info = git_info, trigger_char = "#", - runner = runner, - bufnr = 42, - } + }, overrides), + lists +end + +T["issue_capability_uses_cache_and_runner"] = function() + local adapter, requests = counting_adapter() + local runner, runs = answering_runner({ issue(1) }) + local args, lists = capability_args({ adapter = adapter, runner = runner }) local job = issue_capability.complete(args) local cached_job = issue_capability.complete(args) - eq(request_count, 1, "issue capability requests once") - eq(runner_count, 1, "issue capability runs once") - eq(callback_count, 2, "issue capability callbacks on miss and hit") - eq(last_list.items[1].label, "one", "issue capability caches mapped items") + eq(requests.count, 1, "issue capability requests once") + eq(runs.count, 1, "issue capability runs once") + eq(#lists, 2, "issue capability callbacks on miss and hit") + eq(lists[2].items[1].label, "#1: issue 1", "a hit formats the cached issue") + eq(lists[2].items[1].data, issue(1), "a hit gives the raw issue in data") neq(job, nil, "issue capability returns started job") eq(cached_job, nil, "issue capability cache hit returns no job") end +T["a_hit_formats_the_items_for_its_request"] = function() + local adapter = counting_adapter() + local runner = answering_runner({ issue(1), issue(2) }) + local args, lists = capability_args({ adapter = adapter, runner = runner }) + issue_capability.complete(args) + + -- A new session, so that the items come from the file + cache.forget() + local custom = config.normalize({ + github = { + issues = { + format = { + label = function(_trigger_char, item) + return "custom " .. item.number + end, + }, + }, + }, + }).github.issues + args.config = custom + args.cache = cache.entry(git_info, "github-issues", custom) + args.trigger_char = "!" + issue_capability.complete(args) + + eq(#lists, 2, "a list for each request") + eq( + vim.tbl_map(function(item) + return item.label + end, lists[2].items), + { "custom 1", "custom 2" }, + "the format of the request" + ) + eq(lists[2].items[1].filterText, "! 1 issue 1", "the trigger character of the request") +end + local EMPTY = { items = {}, isIncomplete = false } ---@param runner table ---@return cmp_git.Provider.CapabilityArgs args ---@return cmp_git.CompletionList[] lists local function failing_args(runner) - local callback, lists = helpers.recorder() - return { + return capability_args({ adapter = { issues_request = function() return { commands = {}, handle_item = function() end } end, }, - cache = {}, - callback = callback, - config = {}, - git_info = {}, - trigger_char = "#", runner = runner, - bufnr = 42, - }, - lists + }) end T["failure_gives_an_empty_list_and_is_not_cached"] = function() @@ -95,7 +153,8 @@ T["failure_gives_an_empty_list_and_is_not_cached"] = function() issue_capability.complete(args) eq(lists, { EMPTY, EMPTY }, "each request gets one empty list") - eq(args.cache[42], nil, "a failure is not cached") + eq(args.cache:get(), nil, "a failure is not cached") + eq(vim.fn.filereadable(args.cache.path), 0, "a failure writes no file") eq(runs, 2, "the next request tries again") end @@ -108,7 +167,41 @@ T["no_runnable_command_gives_an_empty_list"] = function() eq(issue_capability.complete(args), nil, "no job") eq(lists, { EMPTY }, "one empty list") - eq(args.cache[42], nil, "nothing is cached") + eq(args.cache:get(), nil, "nothing is cached") +end + +T["a_refresh_gives_the_raw_items"] = function() + local adapter = counting_adapter() + local runner = answering_runner({ issue(1) }) + local results = {} + + issue_capability.refresh({ + adapter = adapter, + config = config.normalize({}).github.issues, + git_info = git_info, + trigger_char = "#", + runner = runner, + }, function(items) + table.insert(results, items) + end) + + eq(results, { { issue(1) } }, "the raw issues") + + local failed = {} + issue_capability.refresh({ + adapter = adapter, + config = config.normalize({}).github.issues, + git_info = git_info, + trigger_char = "#", + runner = { + build_fallback_list = function() + return nil + end, + }, + }, function(items) + table.insert(failed, items == nil) + end) + eq(failed, { true }, "nil when no command can run") end return T diff --git a/tests/provider/capabilities/test_mentions.lua b/tests/provider/capabilities/test_mentions.lua index f953cd8..3455f03 100644 --- a/tests/provider/capabilities/test_mentions.lua +++ b/tests/provider/capabilities/test_mentions.lua @@ -1,10 +1,41 @@ local helpers = require("tests.helpers") +local cache = require("cmp_git.cache") +local config = require("cmp_git.config") +local format = require("cmp_git.format") local mention_capability = require("cmp_git.provider.capabilities.mentions") local eq = helpers.eq local T = helpers.new_set() +local git_info = { host = "github.com", owner = "owner", repo = "repo" } + +---The GitHub mentions config with a limit. +---@param limit number +---@return table +local function mentions_config(limit) + return config.normalize({ github = { mentions = { limit = limit } } }).github.mentions +end + +---@param capability_config table +---@return cmp_git.CacheEntry +local function entry_for(capability_config) + return cache.entry(git_info, "github-mentions", capability_config) +end + +---An adapter request that formats its users like the real adapters. +---@param opts table +---@param capability_config table +---@param trigger_char string +local function mentions_request(opts, capability_config, trigger_char) + return { + commands = { { exec = "mentions", args = {}, page = opts.page, page_size = opts.page_size } }, + handle_item = function(user) + return format.item(capability_config, trigger_char, user) + end, + } +end + T["mention_capability_preflight_and_pagination"] = function() local request_pages = {} local runner = { @@ -22,7 +53,7 @@ T["mention_capability_preflight_and_pagination"] = function() local count = page == 1 and 100 or 1 local items = {} for i = 1, count do - table.insert(items, handle_item({ name = page .. "-" .. i })) + table.insert(items, handle_item({ login = page .. "-" .. i })) end callback({ items = items, isIncomplete = false }, true) end) @@ -38,27 +69,22 @@ T["mention_capability_preflight_and_pagination"] = function() end, } end, - mentions_request = function(_git_info, _trigger_char, _config, opts) + mentions_request = function(_git_info, trigger_char, capability_config, opts) eq(opts.context.member_type, "collaborators", "mention context comes from preflight") - return { - commands = { { exec = "mentions", args = {}, page = opts.page } }, - handle_item = function(item) - return { label = item.name } - end, - } + return mentions_request(opts, capability_config, trigger_char) end, } local callback, callbacks = helpers.recorder() + local capability_config = mentions_config(200) mention_capability.complete({ adapter = adapter, - cache = {}, + cache = entry_for(capability_config), callback = callback, - config = { limit = 200 }, - git_info = {}, + config = capability_config, + git_info = git_info, trigger_char = "@", runner = runner, - bufnr = 43, }) eq(table.concat(request_pages, ","), "1,2", "mention capability paginates") @@ -74,7 +100,7 @@ end ---@field cancelled boolean ---@field respond fun(users: string[]?) Complete the job with the users, or fail it when `users` is nil. ----A runner whose jobs wait until the case completes them, with an adapter that makes users from strings. +---A runner whose jobs wait until the case completes them, with an adapter that makes users from logins. ---@return table runner ---@return table adapter ---@return tests.MentionJob[] jobs The started jobs, in order. @@ -89,24 +115,22 @@ local function manual_provider() function job.cancel() job.cancelled = true end - function job.respond(users) - if not users then + function job.respond(logins) + if not logins then callback({ items = {}, isIncomplete = false }, false) return end + local users = vim.tbl_map(function(login) + return { login = login } + end, logins) callback({ items = vim.tbl_map(handle_item, users), isIncomplete = false }, true) end return job end, } local adapter = { - mentions_request = function(_git_info, _trigger_char, _config, opts) - return { - commands = { { exec = "fake", args = {}, page = opts.page, page_size = opts.page_size } }, - handle_item = function(user) - return { label = user } - end, - } + mentions_request = function(_git_info, trigger_char, capability_config, opts) + return mentions_request(opts, capability_config, trigger_char) end, } return runner, adapter, jobs @@ -131,30 +155,40 @@ local function labels(list) end, list.items) end +---@param logins string[] +---@return string[] +local function mention_labels(logins) + return vim.tbl_map(function(login) + return "@" .. login + end, logins) +end + ---@param runner table ---@param adapter table ----@param cache table ----@param limit integer +---@param limit number +---@param capability_config? table The config of the request, by default the mentions config with the limit. ---@return cmp_git.CompletionList[] lists ---@return { cancel: fun() }? controller -local function request(runner, adapter, cache, limit) +---@return cmp_git.CacheEntry entry +local function request(runner, adapter, limit, capability_config) + capability_config = capability_config or mentions_config(limit) local callback, lists = helpers.recorder() + local entry = entry_for(capability_config) local controller = mention_capability.complete({ adapter = adapter, - cache = cache, + cache = entry, callback = callback, - config = { limit = limit }, - git_info = {}, + config = capability_config, + git_info = git_info, trigger_char = "@", runner = runner, - bufnr = 7, }) - return lists, controller + return lists, controller, entry end T["fewer_users_than_the_limit_need_one_page"] = function() local runner, adapter, jobs = manual_provider() - local lists = request(runner, adapter, {}, 100) + local lists = request(runner, adapter, 100) jobs[1].respond(users(30)) @@ -166,7 +200,7 @@ end T["the_page_size_is_constant_and_the_result_is_cut_to_the_limit"] = function() local runner, adapter, jobs = manual_provider() - local lists = request(runner, adapter, {}, 150) + local lists = request(runner, adapter, 150) jobs[1].respond(users(100)) jobs[2].respond(users(100, 101)) @@ -176,51 +210,45 @@ T["the_page_size_is_constant_and_the_result_is_cut_to_the_limit"] = function() eq(#lists, 2, "a list for each page") eq(lists[1].isIncomplete, true, "the first page is incomplete") eq(lists[2].isIncomplete, false, "the second page is final") - eq(labels(lists[2]), users(150), "users 1 to 150") + eq(labels(lists[2]), mention_labels(users(150)), "users 1 to 150") end -T["a_cancel_removes_the_entry"] = function() +T["a_cancel_removes_the_fetch"] = function() local runner, adapter, jobs = manual_provider() - local cache = {} - local lists, controller = request(runner, adapter, cache, 100) + local lists, controller, entry = request(runner, adapter, 100) helpers.not_nil(controller):cancel() eq(jobs[1].cancelled, true, "the job is cancelled") - eq(cache[7], nil, "the cancelled fetch is not cached") + eq(entry.fetch, nil, "the entry has no fetch") + eq(entry:get(), nil, "the cancelled fetch is not cached") + eq(vim.fn.filereadable(entry.path), 0, "the cancelled fetch writes no file") eq(lists, {}, "a cancelled request gets no list") - local next_lists = request(runner, adapter, cache, 100) + local next_lists = request(runner, adapter, 100) eq(#jobs, 2, "the next request fetches again") jobs[2].respond(users(3)) - eq(next_lists, { - { - items = vim.tbl_map(function(user) - return { label = user } - end, users(3)), - isIncomplete = false, - }, - }, "the next request gets the users") + eq(#next_lists, 1, "one list") + eq(next_lists[1].isIncomplete, false, "the list is final") + eq(labels(next_lists[1]), mention_labels(users(3)), "the next request gets the users") end T["a_failure_gives_an_empty_list_and_is_not_cached"] = function() local runner, adapter, jobs = manual_provider() - local cache = {} - local lists = request(runner, adapter, cache, 100) + local lists, _, entry = request(runner, adapter, 100) jobs[1].respond(nil) eq(lists, { { items = {}, isIncomplete = false } }, "one empty list") - eq(cache[7], nil, "a failure is not cached") + eq(entry:get(), nil, "a failure is not cached") - request(runner, adapter, cache, 100) + request(runner, adapter, 100) eq(#jobs, 2, "the next request fetches again") end T["a_failed_later_page_keeps_the_earlier_users"] = function() local runner, adapter, jobs = manual_provider() - local cache = {} - local lists = request(runner, adapter, cache, 200) + local lists, _, entry = request(runner, adapter, 200) jobs[1].respond(users(100)) jobs[2].respond(nil) @@ -228,11 +256,10 @@ T["a_failed_later_page_keeps_the_earlier_users"] = function() eq(#lists, 2, "an incomplete list, then the final list") eq(lists[2].isIncomplete, false, "the last list is final") eq(#lists[2].items, 100, "the users of the first page") - eq(cache[7], nil, "a failure is not cached") + eq(entry:get(), nil, "a failure is not cached") end T["no_runnable_command_gives_an_empty_list"] = function() - local cache = {} local runner = { build_fallback = function() return nil @@ -250,10 +277,10 @@ T["no_runnable_command_gives_an_empty_list"] = function() end, } - local lists = request(runner, adapter, cache, 100) + local lists, _, entry = request(runner, adapter, 100) eq(lists, { { items = {}, isIncomplete = false } }, "one empty list") - eq(cache[7], nil, "nothing is cached") + eq(entry:get(), nil, "nothing is cached") end T["a_failed_preflight_still_fetches"] = function() @@ -274,7 +301,7 @@ T["a_failed_preflight_still_fetches"] = function() } end - local lists = request(runner, adapter, {}, 100) + local lists = request(runner, adapter, 100) jobs[1].respond(users(2)) eq(preflight_results, { { "", false } }, "handle_result gets the failure") @@ -284,14 +311,14 @@ end T["requests_during_a_fetch_share_it"] = function() local runner, adapter, jobs = manual_provider() - local cache = {} - local first, first_controller = request(runner, adapter, cache, 300) - local second = request(runner, adapter, cache, 300) + local capability_config = mentions_config(300) + local first, first_controller, entry = request(runner, adapter, 300, capability_config) + local second = request(runner, adapter, 300, capability_config) eq(#jobs, 1, "the second request does not fetch") eq(second, {}, "no users yet") jobs[1].respond(users(100)) - local third = request(runner, adapter, cache, 300) + local third = request(runner, adapter, 300, capability_config) eq(#third, 1, "a request during the fetch gets the users so far") eq(third[1].isIncomplete, true, "and more come") @@ -307,11 +334,63 @@ T["requests_during_a_fetch_share_it"] = function() eq(#final.items, 105, name .. " gets all users") end eq(#first, 1, "the cancelled request gets no more lists") - eq(#helpers.not_nil(cache[7]).items, 105, "the users are cached") + eq(#helpers.not_nil(entry:get()), 105, "the users are cached") - local cached = request(runner, adapter, cache, 300) - eq(#jobs, 2, "a cache hit does not fetch") + cache.forget() + local cached = request(runner, adapter, 300, capability_config) + eq(#jobs, 2, "a cache hit in a new session does not fetch") eq(cached[1].isIncomplete, false, "a cache hit is final") + eq(#cached[1].items, 105, "a cache hit gives the users of the file") +end + +T["each_request_formats_the_users_with_its_config"] = function() + local runner, adapter, jobs = manual_provider() + local capability_config = mentions_config(100) + local custom = vim.deepcopy(capability_config) + custom.format.label = function(_trigger_char, user) + return "custom " .. user.login + end + local first = request(runner, adapter, 100, capability_config) + local second = request(runner, adapter, 100, custom) + + jobs[1].respond(users(1)) + + eq(labels(first[1]), { "@user1" }, "the default format") + eq(labels(second[1]), { "custom user1" }, "the format of the second request") +end + +T["a_refresh_gets_all_pages"] = function() + local runner, adapter, jobs = manual_provider() + local results = {} + + mention_capability.refresh({ + adapter = adapter, + config = mentions_config(math.huge), + git_info = git_info, + trigger_char = "@", + runner = runner, + }, function(items) + table.insert(results, items) + end) + jobs[1].respond(users(100)) + eq(results, {}, "the refresh waits for the last page") + jobs[2].respond(users(1, 101)) + + eq(#results, 1, "one result") + eq(#results[1], 101, "the raw users of both pages") + eq(results[1][101], { login = "user101" }, "a raw user") + + mention_capability.refresh({ + adapter = adapter, + config = mentions_config(math.huge), + git_info = git_info, + trigger_char = "@", + runner = runner, + }, function(items) + table.insert(results, items == nil) + end) + jobs[3].respond(nil) + eq(results[2], true, "nil when a page fails") end return T diff --git a/tests/sources/test_github.lua b/tests/sources/test_github.lua index 06362ad..de03f4c 100644 --- a/tests/sources/test_github.lua +++ b/tests/sources/test_github.lua @@ -1,4 +1,5 @@ local helpers = require("tests.helpers") +local cache = require("cmp_git.cache") local command = require("cmp_git.command") local config = require("cmp_git.config") local repository = require("cmp_git.repository") @@ -29,11 +30,6 @@ T["github_instance_state_isolation"] = function() second:is_valid_host({ host = "github.com", owner = "owner", repo = "repo" }), "GitHub instance validates default host" ) - - first.cache.issues[1] = {} - eq(second.cache.issues[1], nil, "GitHub issue cache is instance-local") - ok(first.cache.mentions ~= second.cache.mentions, "GitHub mention cache table is instance-local") - ok(first.cache.pull_requests ~= second.cache.pull_requests, "GitHub PR cache table is instance-local") end local git_info = { host = "github.com", owner = "owner", repo = "repo" } @@ -93,14 +89,22 @@ local function labels(items) end, items) end +---@param items table[]? +---@return integer[] +local function numbers(items) + return vim.tbl_map(function(item) + return item.number + end, items or {}) +end + T["issues_and_change_requests_keep_the_caches_separate"] = function() local source, requests = fake_github() local callback, lists = helpers.recorder() - local bufnr = vim.api.nvim_get_current_buf() source:complete_issues_and_change_requests(callback, git_info, "#") eq(labels(lists[1].items), { "#1: an issue", "#2: a pull request" }, "issues, then pull requests") - eq(labels(source.cache.issues[bufnr]), { "#1: an issue" }, "the issue cache has no pull requests") + eq(numbers(source:_entry("issues", git_info):get()), { 1 }, "the issue cache has no pull requests") + eq(numbers(source:_entry("pull_requests", git_info):get()), { 2 }, "the pull request cache has no issues") source:complete_issues_and_change_requests(callback, git_info, "#") eq( @@ -184,6 +188,7 @@ end T["issues_and_change_requests_keep_the_items_of_a_request_that_succeeds"] = function() for _, failed in ipairs({ "issue", "pr" }) do + cache.clear_all() local source, jobs = manual_github() local callback, lists = helpers.recorder() @@ -288,8 +293,8 @@ T["the_default_repository_is_looked_up_once_without_blocking"] = function() eq(info, remote(), "the git info of the caller does not change") eq(gh_calls(), { cwd }, "gh runs in the directory of the buffer") - -- A buffer without cached lists, so that the requests need the repository - vim.api.nvim_set_current_buf(helpers.buffer({}, "markdown")) + -- Without cached lists, the requests need the repository + cache.clear_all() source:complete_mentions(callback, remote(), "@") source:complete_issues_and_change_requests(callback, remote(), "#") @@ -323,7 +328,7 @@ T["without_a_default_repository_the_remote_is_used"] = function() helpers.wait_for(function() return #lists == 1 end, "the issues answer") - vim.api.nvim_set_current_buf(helpers.buffer({}, "markdown")) + cache.clear_all() source:complete_issues(callback, remote(), "#") eq(#lists, 2, "the second request answers at once") @@ -333,10 +338,9 @@ end T["a_cache_hit_does_not_run_gh"] = function() local source, repos = recording_github("upstream/project") - local bufnr = vim.api.nvim_get_current_buf() - source.cache.issues[bufnr] = {} - source.cache.change_requests[bufnr] = {} - source.cache.mentions[bufnr] = { items = {}, in_progress = false } + for _, kind in ipairs({ "issues", "pull_requests", "mentions" }) do + source:_entry(kind, remote()):set({}) + end local callback, lists = helpers.recorder() for _, method in ipairs(vim.list_extend({ "complete_mentions" }, REQUESTS)) do @@ -353,7 +357,7 @@ T["without_gh_the_remote_is_used"] = function() local callback, lists = helpers.recorder() for _, method in ipairs(REQUESTS) do - vim.api.nvim_set_current_buf(helpers.buffer({}, "markdown")) + cache.clear_all() source[method](source, callback, remote(), "#") end @@ -389,24 +393,152 @@ T["a_cancel_during_the_lookup_starts_no_request"] = function() eq(lists, {}, "a cancelled request gets no list") end -T["requests_cache_under_the_request_buffer"] = function() +T["requests_from_two_buffers_share_the_entries_of_the_remote"] = function() local source = recording_github() - local request_bufnr = helpers.buffer({}, "markdown") - local current_bufnr = helpers.buffer({}, "markdown") - vim.api.nvim_set_current_buf(current_bufnr) + local first_bufnr = helpers.buffer({}, "markdown") + local second_bufnr = helpers.buffer({}, "markdown") local callback, lists = helpers.recorder() for _, request in ipairs(REQUESTS) do - source[request](source, callback, remote(), "#", request_bufnr) + source[request](source, callback, remote(), "#", first_bufnr) end helpers.wait_for(function() return #lists == #REQUESTS end, "the requests answer") - ok(source.cache.issues[request_bufnr] ~= nil, "the issues are cached for the request buffer") - ok(source.cache.change_requests[request_bufnr] ~= nil, "the pull requests are cached for the request buffer") - eq(source.cache.issues[current_bufnr], nil, "the current buffer gets no issues") - eq(source.cache.change_requests[current_bufnr], nil, "the current buffer gets no pull requests") + local requests = {} + helpers.stub(command, "build_fallback_list", function() + table.insert(requests, true) + end) + for _, request in ipairs(REQUESTS) do + source[request](source, callback, remote(), "#", second_bufnr) + end + + eq(#lists, #REQUESTS * 2, "the requests of the second buffer answer at once") + eq(#requests, 0, "from the cache") +end + +T["instances_share_the_entries_of_a_remote"] = function() + local first, requests = fake_github() + local second = GitHub.new(config.normalize({}).github) + local callback, lists = helpers.recorder() + + first:complete_issues_and_change_requests(callback, git_info, "#") + second:complete_issues_and_change_requests(callback, git_info, "#") + + eq(labels(lists[2].items), { "#1: an issue", "#2: a pull request" }, "the second instance gets the items") + eq(requests, { issue = 1, pr = 1 }, "each command runs once") +end + +T["a_new_session_answers_from_the_file_without_gh"] = function() + local source, repos, gh_calls = recording_github("upstream/project") + local callback, lists = helpers.recorder() + source:complete_issues_and_change_requests(callback, remote(), "#") + helpers.wait_for(function() + return #lists == 1 + end, "the first request answers") + + cache.forget() + local next_source = GitHub.new(config.normalize({}).github) + next_source:complete_issues_and_change_requests(callback, remote(), "#") + + eq(#lists, 2, "the request answers at once") + eq(#repos, 2, "no command runs for the second request") + eq(#gh_calls(), 1, "gh runs only for the first request") +end + +---Make the cached items of the entry older than the default TTL. +---@param entry cmp_git.CacheEntry +local function age(entry) + entry.fetched_at = os.time() - config.normalize({}).cache.ttl +end + +T["stale_items_answer_at_once_and_refresh_from_the_default_repository"] = function() + local source, repos, gh_calls = recording_github("upstream/project") + local entry = source:_entry("issues", remote()) + entry:set({ records.issue }) + age(entry) + local callback, lists = helpers.recorder() + + source:complete_issues(callback, remote(), "#") + eq(#lists, 1, "the request answers at once") + eq(labels(lists[1].items), { "#1: an issue" }, "with the stale items") + + source:complete_issues(callback, remote(), "#") + helpers.wait_for(function() + return #repos > 0 and not entry.refreshing + end, "the refresh ends") + helpers.flush() + + eq(#lists, 2, "the second request answers at once too") + eq(repos, { "upstream/project" }, "one refresh, from the default repository") + eq(#gh_calls(), 1, "gh looks up the default repository") + eq(entry:get(), {}, "the refresh stores its items") + ok(not entry:is_stale(config.normalize({}).cache.ttl), "the items are fresh") +end + +T["the_ttl_comes_from_the_config"] = function() + local _, repos = recording_github(nil) + local source = GitHub.new(config.normalize({}).github, { ttl = math.huge }) + local entry = source:_entry("issues", remote()) + entry:set({ records.issue }) + entry.fetched_at = 0 + local callback = helpers.recorder() + + source:complete_issues(callback, remote(), "#") + + eq(repos, {}, "math.huge never refreshes") +end + +T["a_failed_refresh_keeps_the_stale_items"] = function() + local source = recording_github(nil) + local runs = 0 + helpers.stub(command, "build_fallback_list", function(_commands, callback) + return helpers.fake_job(function() + runs = runs + 1 + callback({ items = {}, isIncomplete = false }, false) + end) + end) + local entry = source:_entry("issues", remote()) + entry:set({ records.issue }) + age(entry) + local callback, lists = helpers.recorder() + + source:complete_issues(callback, remote(), "#") + source:complete_issues(callback, remote(), "#") + + eq(runs, 1, "the second request waits a TTL before it refreshes again") + eq(labels(lists[2].items), { "#1: an issue" }, "the stale items stay") +end + +T["a_stale_combined_request_refreshes_both_lists"] = function() + local source, repos = recording_github(nil) + for _, kind in ipairs({ "issues", "pull_requests" }) do + local entry = source:_entry(kind, remote()) + entry:set({}) + age(entry) + end + local callback, lists = helpers.recorder() + + source:complete_issues_and_change_requests(callback, remote(), "#") + + eq(#lists, 1, "the request answers at once") + eq(repos, { "owner/repo", "owner/repo" }, "the issues and the pull requests refresh") +end + +T["stale_mentions_refresh_all_pages"] = function() + local source, repos = recording_github(nil) + local entry = source:_entry("mentions", remote()) + entry:set({ { login = "octocat" } }) + age(entry) + local callback, lists = helpers.recorder() + + source:complete_mentions(callback, remote(), "@") + + eq(labels(lists[1].items), { "@octocat" }, "the request answers with the stale users") + eq(repos, { "owner/repo", "owner/repo" }, "the refresh runs the preflight and the first page") + eq(entry:get(), {}, "the refresh stores the users") + eq(entry.fetch, nil, "the refresh does not block the requests") end return T diff --git a/tests/sources/test_gitlab.lua b/tests/sources/test_gitlab.lua index 1080028..c98779f 100644 --- a/tests/sources/test_gitlab.lua +++ b/tests/sources/test_gitlab.lua @@ -1,4 +1,5 @@ local helpers = require("tests.helpers") +local cache = require("cmp_git.cache") local config = require("cmp_git.config") local GitLab = require("cmp_git.sources.gitlab") @@ -27,11 +28,6 @@ T["gitlab_instance_state_isolation"] = function() second:is_valid_host({ host = "gitlab.com", owner = "owner", repo = "repo" }), "GitLab instance validates default host" ) - - first.cache.issues[1] = {} - eq(second.cache.issues[1], nil, "GitLab issue cache is instance-local") - ok(first.cache.mentions ~= second.cache.mentions, "GitLab mention cache table is instance-local") - ok(first.cache.merge_requests ~= second.cache.merge_requests, "GitLab MR cache table is instance-local") end local git_info = { host = "gitlab.com", owner = "owner", repo = "repo" } @@ -70,14 +66,13 @@ end T["failed_commands_give_an_empty_list"] = function() fake_commands({ glab = "exit 1", curl = "exit 22" }) local source = GitLab.new(config.normalize({}).gitlab) - local bufnr = vim.api.nvim_get_current_buf() eq(answers(source, "complete_issues", "#"), { EMPTY }, "issues") eq(answers(source, "complete_change_requests", "!"), { EMPTY }, "merge requests") eq(answers(source, "complete_mentions", "@"), { EMPTY }, "mentions") - eq(source.cache.issues[bufnr], nil, "failed issues are not cached") - eq(source.cache.change_requests[bufnr], nil, "failed merge requests are not cached") - eq(source.cache.mentions[bufnr], nil, "failed mentions are not cached") + eq(source:_entry("issues", git_info):get(), nil, "failed issues are not cached") + eq(source:_entry("merge_requests", git_info):get(), nil, "failed merge requests are not cached") + eq(source:_entry("mentions", git_info):get(), nil, "failed mentions are not cached") end T["missing_commands_give_an_empty_list"] = function() @@ -89,4 +84,49 @@ T["missing_commands_give_an_empty_list"] = function() eq(answers(source, "complete_mentions", "@"), { EMPTY }, "mentions") end +---@param list cmp_git.CompletionList +---@return string[] +local function labels(list) + return vim.tbl_map(function(item) + return item.label + end, list.items) +end + +T["glab_items_are_cached_for_the_next_session"] = function() + fake_commands({ + glab = [[printf '%s' '[{"iid":1,"title":"an issue","description":null,"updated_at":"2024-01-01T00:00:00Z"}]']], + }) + local source = GitLab.new(config.normalize({}).gitlab) + eq(labels(answers(source, "complete_issues", "#")[1]), { "#1: an issue" }, "the issues of glab") + + cache.forget() + fake_commands({ glab = "exit 1" }) + local callback, lists = helpers.recorder() + GitLab.new(config.normalize({}).gitlab):complete_issues(callback, git_info, "#") + + eq(#lists, 1, "a new session answers at once") + eq(labels(lists[1]), { "#1: an issue" }, "with the issues of the file") + eq(lists[1].items[1].data.description, "", "the null description of the API is still empty") +end + +T["stale_items_refresh_in_the_background"] = function() + fake_commands({ + glab = [[printf '%s' '[{"iid":2,"title":"a new issue","description":"","updated_at":"2024-01-01T00:00:00Z"}]']], + }) + local source = GitLab.new(config.normalize({}).gitlab, { ttl = 60 }) + local entry = source:_entry("issues", git_info) + entry:set({ { iid = 1, title = "an issue", description = "", updated_at = "2024-01-01T00:00:00Z" } }) + entry.fetched_at = os.time() - 60 + local callback, lists = helpers.recorder() + + source:complete_issues(callback, git_info, "#") + eq(labels(lists[1]), { "#1: an issue" }, "the request answers at once with the stale items") + + helpers.wait_for(function() + return not entry.refreshing + end, "the refresh ends") + source:complete_issues(callback, git_info, "#") + eq(labels(lists[2]), { "#2: a new issue" }, "the next request gets the new items") +end + return T diff --git a/tests/test_cache.lua b/tests/test_cache.lua new file mode 100644 index 0000000..4b09f5c --- /dev/null +++ b/tests/test_cache.lua @@ -0,0 +1,203 @@ +local helpers = require("tests.helpers") +local cache = require("cmp_git.cache") + +local eq = helpers.eq +local neq = helpers.neq +local ok = helpers.ok + +local T = helpers.new_set() + +local remote = { host = "github.com", owner = "owner", repo = "repo" } +local config = { state = "open", limit = 100, fields = { "title", "number" } } + +---@param path string +---@return integer mode The permission bits. +local function mode(path) + return require("bit").band(helpers.not_nil(vim.uv.fs_stat(path), path .. " exists").mode, tonumber("777", 8)) +end + +---@param path string +---@param content string +local function write_file(path, content) + vim.fn.mkdir(vim.fs.dirname(path), "p") + local file = assert(io.open(path, "wb")) + file:write(content) + file:close() +end + +T["the_same_arguments_give_the_same_entry"] = function() + local entry = cache.entry(remote, "github-issues", config) + + ok(cache.entry(vim.deepcopy(remote), "github-issues", vim.deepcopy(config)) == entry, "equal arguments") + neq(cache.entry(remote, "github-pull_requests", config).path, entry.path, "another kind") + neq(cache.entry(remote, "github-issues", { state = "all", limit = 100 }).path, entry.path, "another state") + neq(cache.entry(remote, "github-issues", { state = "open", limit = 5 }).path, entry.path, "another limit") + eq( + cache.entry(remote, "github-issues", vim.tbl_extend("force", config, { format = {} })).path, + entry.path, + "a field that does not change the query" + ) +end + +T["each_remote_has_its_own_directory"] = function() + local function dir(host, owner, repo) + return cache.entry({ host = host, owner = owner, repo = repo }, "gitlab-issues", config).dir + end + + eq(dir("github.com", "owner", "repo"), cache.root() .. "/github.com/owner/repo", "a readable path") + neq(dir("example.com/gitlab", "a", "r"), dir("example.com", "gitlab/a", "r"), "a path prefix is not a group") + eq(dir("example.com:8443", "a/b", "r"), cache.root() .. "/example.com%3A8443/a%2Fb/r", "ports and subgroups") + eq(vim.fn.stdpath("cache") .. "/cmp-git", cache.root(), "the cache directory of Neovim") +end + +T["a_new_session_reads_the_items_from_the_file"] = function() + local items = { { number = 1, title = "an issue", body = vim.NIL, labels = { "bug" } } } + cache.entry(remote, "github-issues", config):set(items) + + cache.forget() + local entry = cache.entry(remote, "github-issues", config) + eq(entry:get(), items, "the items of the file, with null as vim.NIL") + ok(math.abs(helpers.not_nil(entry.fetched_at) - os.time()) <= 1, "the time of the fetch") +end + +T["only_the_user_can_read_the_files"] = function() + local entry = cache.entry({ host = "example.com/gitlab", owner = "a/b", repo = "r" }, "gitlab-issues", config) + entry:set({}) + + eq(mode(entry.path), tonumber("600", 8), "the file") + for _, dir in ipairs({ entry.dir, vim.fs.dirname(entry.dir), cache.root() }) do + eq(mode(dir), tonumber("700", 8), dir) + end + eq(vim.fn.glob(entry.dir .. "/*.tmp.*"), "", "no temporary file stays") +end + +T["a_file_that_is_not_an_entry_is_a_miss"] = function() + local files = { + ["invalid JSON"] = "{", + ["another version"] = vim.json.encode({ version = 2, fetched_at = 1, items = {} }), + ["no time"] = vim.json.encode({ version = 1, items = {} }), + ["no list"] = vim.json.encode({ version = 1, fetched_at = 1, items = { a = 1 } }), + ["not an object"] = "[]", + } + for name, content in pairs(files) do + cache.forget() + local entry = cache.entry(remote, "github-issues", config) + write_file(entry.path, content) + + eq(entry:get(), nil, name) + + entry:set({ { number = 2 } }) + cache.forget() + eq( + cache.entry(remote, "github-issues", config):get(), + { { number = 2 } }, + name .. ": the next fetch replaces it" + ) + end +end + +T["a_miss_reads_the_file_once"] = function() + local entry = cache.entry(remote, "github-issues", config) + eq(entry:get(), nil, "no file") + + write_file(entry.path, vim.json.encode({ version = 1, fetched_at = os.time(), items = { {} } })) + eq(entry:get(), nil, "the entry does not read the file again") +end + +T["stale_items_refresh_once_in_the_background"] = function() + local entry = cache.entry(remote, "github-issues", config) + local starts = {} + local function start(done) + table.insert(starts, done) + end + + entry:refresh(0, start) + eq(#starts, 0, "an entry without items does not refresh") + + entry:set({ { number = 1 } }) + entry:refresh(300, start) + eq(#starts, 0, "fresh items do not refresh") + + entry.fetched_at = os.time() - 300 + ok(entry:is_stale(300), "items of the age of the TTL are stale") + entry:refresh(300, start) + entry:refresh(300, start) + eq(#starts, 1, "one refresh runs at a time") + eq(entry:get(), { { number = 1 } }, "the stale items stay during the refresh") + + starts[1]({ { number = 2 } }) + eq(entry:get(), { { number = 2 } }, "the refresh stores its items") + ok(not entry:is_stale(300), "the new items are fresh") + cache.forget() + eq(cache.entry(remote, "github-issues", config):get(), { { number = 2 } }, "and writes them") + + entry:refresh(math.huge, start) + eq(#starts, 1, "math.huge never refreshes") +end + +T["a_failed_refresh_keeps_the_items_and_waits_a_ttl"] = function() + local entry = cache.entry(remote, "github-issues", config) + entry:set({ { number = 1 } }) + entry.fetched_at = os.time() - 1000 + local starts = 0 + local function fail(done) + starts = starts + 1 + done(nil) + end + + entry:refresh(300, fail) + eq(entry:get(), { { number = 1 } }, "the items stay") + entry:refresh(300, fail) + eq(starts, 1, "the next refresh waits") + + entry.failed_at = os.time() - 300 + entry:refresh(300, fail) + eq(starts, 2, "a refresh runs again after the TTL") +end + +T["a_clear_removes_one_remote"] = function() + local other = { host = "gitlab.com", owner = "group", repo = "project" } + local entry = cache.entry(remote, "github-issues", config) + local other_entry = cache.entry(other, "gitlab-issues", config) + entry:set({ { number = 1 } }) + other_entry:set({ { iid = 1 } }) + + cache.clear_remote(vim.deepcopy(remote)) + + eq(vim.fn.isdirectory(entry.dir), 0, "the directory of the remote is removed") + eq(vim.fn.filereadable(other_entry.path), 1, "the other remote keeps its file") + ok(cache.entry(remote, "github-issues", config) ~= entry, "a request after the clear gets a new entry") + eq(cache.entry(remote, "github-issues", config):get(), nil, "and no items") + ok(cache.entry(other, "gitlab-issues", config) == other_entry, "the other remote keeps its entry") + + -- A fetch that started before the clear + entry:set({ { number = 2 } }) + entry:refresh(0, function(done) + done({ { number = 3 } }) + end) + eq(vim.fn.isdirectory(entry.dir), 0, "a fetch that ends after the clear writes no file") +end + +T["a_clear_of_all_removes_the_cache_directory"] = function() + local entry = cache.entry(remote, "github-issues", config) + entry:set({ { number = 1 } }) + + cache.clear_all() + + eq(vim.fn.isdirectory(cache.root()), 0, "the directory is removed") + eq(cache.entry(remote, "github-issues", config):get(), nil, "no items") + entry:set({ { number = 2 } }) + eq(vim.fn.isdirectory(cache.root()), 0, "a fetch that ends after the clear writes no file") +end + +T["a_failed_write_keeps_the_items_in_memory"] = function() + -- A file where the cache directory must be + write_file(cache.root(), "") + local entry = cache.entry(remote, "github-issues", config) + + entry:set({ { number = 1 } }) + + eq(entry:get(), { { number = 1 } }, "the items stay in memory") +end + +return T diff --git a/tests/test_commands.lua b/tests/test_commands.lua new file mode 100644 index 0000000..db999c5 --- /dev/null +++ b/tests/test_commands.lua @@ -0,0 +1,154 @@ +local helpers = require("tests.helpers") +local cache = require("cmp_git.cache") +local commands = require("cmp_git.commands") +local config = require("cmp_git.config") +local Core = require("cmp_git.core") +local Git = require("cmp_git.sources.git") + +local eq = helpers.eq +local ok = helpers.ok + +local T = helpers.new_set() + +local ROOT = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":p:h:h") + +local remote = { host = "github.com", owner = "owner", repo = "repo" } +local other_remote = { host = "gitlab.com", owner = "group", repo = "project" } +local issues_config = config.normalize({}).github.issues + +---An entry of the remote with one item. +---@param git_info cmp_git.GitInfo +---@return cmp_git.CacheEntry +local function cached_entry(git_info) + local entry = cache.entry(git_info, "github-issues", issues_config) + entry:set({ { number = 1 } }) + return entry +end + +---@param git_info cmp_git.GitInfo +---@return boolean +local function is_cached(git_info) + cache.forget() + return cache.entry(git_info, "github-issues", issues_config):get() ~= nil +end + +---A git source with cached commits for each buffer. +---@param buffers integer[] +---@return cmp_git.Source.Git +local function git_source(buffers) + local source = Git.new(config.normalize({}).git) + for _, bufnr in ipairs(buffers) do + source.cache_commits[bufnr] = {} + source.cache_trailer_keys[bufnr] = {} + end + return source +end + +---The messages of the notifications so far. +---@return string[] +local function messages() + helpers.flush() + return vim.tbl_map(function(notification) + return notification.message + end, helpers.notifications) +end + +---A buffer of a file in a repository with the remote. The cores of earlier cases are collected first, so that only the +---cores of the case discover the remote. +---@param url string +---@return integer bufnr +local function repository_buffer(url) + collectgarbage("collect") + local repo = helpers.git_repo({ remotes = { { "origin", url } } }) + vim.cmd.edit(vim.fn.fnameescape(repo.dir .. "/COMMIT_EDITMSG")) + return vim.api.nvim_get_current_buf() +end + +T["the_bang_clears_all_remotes_and_buffers"] = function() + cached_entry(remote) + cached_entry(other_remote) + local source = git_source({ 1, 2 }) + + commands.clear_cache(true) + + eq(vim.fn.isdirectory(cache.root()), 0, "the cache directory is removed") + ok(not is_cached(remote) and not is_cached(other_remote), "no remote has items") + eq(source.cache_commits, {}, "no buffer has commits") + eq(source.cache_trailer_keys, {}, "no buffer has trailer keys") + eq(messages(), { "Cleared the cache of all remotes." }, "the user gets a message") +end + +T["without_a_bang_the_command_clears_the_remote_and_the_buffer"] = function() + local bufnr = repository_buffer("git@github.com:owner/repo.git") + local other_bufnr = helpers.buffer({}) + cached_entry(remote) + cached_entry(other_remote) + local source = git_source({ bufnr, other_bufnr }) + + commands.clear_cache(false) + helpers.wait_for(function() + return #messages() > 0 + end, "the command ends") + + ok(not is_cached(remote), "the remote of the buffer has no items") + ok(is_cached(other_remote), "the other remote keeps its items") + eq(source.cache_commits[bufnr], nil, "the buffer has no commits") + eq(source.cache_trailer_keys[bufnr], nil, "the buffer has no trailer keys") + eq(source.cache_commits[other_bufnr], {}, "the other buffer keeps its commits") + eq(messages(), { "Cleared the cache of github.com/owner/repo." }, "the message names the remote") +end + +T["the_remote_comes_from_the_config_of_each_core"] = function() + local bufnr = repository_buffer("git@work:owner/repo.git") + local core = Core.new({ ssh_aliases = { work = "github.com" } }) + cached_entry(remote) + + commands.clear_cache(false, bufnr) + helpers.wait_for(function() + return #messages() > 0 + end, "the command ends") + + ok(not is_cached(remote), "the SSH alias of the core gives the remote") + eq(messages(), { "Cleared the cache of github.com/owner/repo." }, "the message names the remote") + ok(core ~= nil, "the core is alive during the command") +end + +T["a_buffer_without_a_remote_clears_its_commits_and_trailers"] = function() + collectgarbage("collect") + -- Discovery uses the cwd for a buffer without a file. The case hooks restore it. + vim.fn.chdir(helpers.tempdir()) + local bufnr = helpers.buffer({}) + cached_entry(remote) + local source = git_source({ bufnr }) + + commands.clear_cache(false, bufnr) + helpers.wait_for(function() + return #messages() > 0 + end, "the command ends") + + eq(source.cache_commits[bufnr], nil, "the buffer has no commits") + ok(is_cached(remote), "the remote keeps its items") + eq( + messages(), + { "Cleared the commits and trailers of the buffer. The buffer has no remote." }, + "the message says that the buffer has no remote" + ) +end + +T["the_plugin_file_defines_the_command"] = function() + helpers.stub(vim.g, "loaded_cmp_git", nil) + local calls = {} + helpers.stub(commands, "clear_cache", function(all) + table.insert(calls, all) + end) + + dofile(ROOT .. "/plugin/cmp_git.lua") + vim.cmd("CmpGitClearCache") + vim.cmd("CmpGitClearCache!") + vim.api.nvim_del_user_command("CmpGitClearCache") + + eq(calls, { false, true }, "the bang clears all remotes") + eq(vim.g.loaded_cmp_git, true, "the guard is set") +end + +return T diff --git a/tests/test_config.lua b/tests/test_config.lua index b301b4e..4e943bd 100644 --- a/tests/test_config.lua +++ b/tests/test_config.lua @@ -25,6 +25,11 @@ T["config_normalize_fills_defaults_and_hosts"] = function() ok(vim.tbl_contains(normalized.gitlab.hosts, "gitlab.com"), "normalized config adds gitlab host") end +T["config_normalize_fills_the_cache_ttl"] = function() + eq(config.normalize({}).cache.ttl, 300, "the default TTL is five minutes") + eq(config.normalize({ cache = { ttl = 0 } }).cache.ttl, 0, "a user TTL") +end + T["config_normalize_nested_filter_and_sort_overrides"] = function() local issue_filter = function() return "issue-filter"