diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2716a7d..253fb6a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,6 +66,30 @@ jobs: - name: Test run: nix develop ".#${{ matrix.shell }}" -c make test + # Shared runners are noisy, so a failed comparison is a warning to read in the job summary, not a failed check + bench: + name: Benchmarks + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 30 + continue-on-error: true + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The pull request merge commit and its parents. The first parent is the base branch. + fetch-depth: 2 + - name: Install Nix + uses: cachix/install-nix-action@448cde739abec1eaf0646249e3d25bb8d9bc7702 # v31.11.2 + with: + extra_nix_config: | + extra-substituters = https://nix-community.cachix.org + extra-trusted-public-keys = nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs= + - name: Compare with the base branch + env: + BENCH_RUNS: 3 + run: nix develop -c scripts/bench-compare.sh HEAD^1 + # Dependabot and `nix flake update` can lock an overlay commit that is not in the cache yet. Then # `nix develop .#nightly` builds Neovim from source. nightly-cache: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index de671ac..dfec94e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,16 +39,18 @@ extra-trusted-public-keys = nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq ## Make targets -| Target | Action | -| ---------------------------- | --------------------------------------------------------------------------------------------------- | -| `make test` | Runs all tests. | -| `make test-file FILE=` | Runs one test file. | -| `make format` | Formats `lua/` and `tests/` with StyLua. | -| `make format-check` | Checks the formatting. | -| `make lint` | Lints `lua/` and `tests/` with Selene. Warnings fail the target. | -| `make typecheck` | Checks the types with lua-language-server. Warnings fail the target. | -| `make ci` | Runs `format-check`, `lint`, `typecheck`, and `test`. | -| `make test-deps` | Clones mini.nvim at `MINI_VERSION` into `deps/`. `test`, `test-file`, and `typecheck` run it first. | +| Target | Action | +| ------------------------------ | --------------------------------------------------------------------------------------------------- | +| `make test` | Runs all tests. | +| `make test-file FILE=` | Runs one test file. | +| `make format` | Formats `lua/`, `tests/`, and `benchmarks/` with StyLua. | +| `make format-check` | Checks the formatting. | +| `make lint` | Lints `lua/`, `tests/`, and `benchmarks/` with Selene. Warnings fail the target. | +| `make typecheck` | Checks the types with lua-language-server. Warnings fail the target. | +| `make ci` | Runs `format-check`, `lint`, `typecheck`, and `test`. | +| `make test-deps` | Clones mini.nvim at `MINI_VERSION` into `deps/`. `test`, `test-file`, and `typecheck` run it first. | +| `make bench` | Runs the benchmarks. Options go in `BENCH_ARGS`. | +| `make bench-compare REF=` | Compares the benchmarks of `` (default `main`) with the working tree. | To use a different tool, set its variable: `NVIM`, `STYLUA`, `SELENE`, or `LUA_LANGUAGE_SERVER`. For example: @@ -101,18 +103,72 @@ nix develop .#nightly --no-write-lock-file \ --override-input neovim-nightly-overlay "github:nix-community/neovim-nightly-overlay/$rev" -c make test ``` +## Benchmarks + +The benchmarks in `benchmarks/` measure the two costs that users feel: the work that runs while they type, and the time +until the completion items show. Nothing in them uses the network. + +| Group | Cases | Measures | +| ------------ | -------------------------------- | ------------------------------------------------------------------------------------- | +| `keystroke/` | `benchmarks/cases/keystroke.lua` | The checks that nvim-cmp and blink.cmp make on each keystroke, and declined requests. | +| `request/` | `benchmarks/cases/request.lua` | Routing, parsing, and item formatting for one request. Also the startup cost. | +| `e2e/` | `benchmarks/cases/e2e.lua` | One request from start to the final callback, in a git repository with 1000 commits. | + +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. + +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 +`setup_and_a_declined_request_load_only_the_core_modules` in `tests/test_core.lua` lists the modules that `setup` and a +declined request load. A new `require` at the top of one of these modules adds to the cost of each session. Put the +`require` in the code that needs the module. + +Each line of the report has these values: + +| Value | Meaning | +| ---------- | -------------------------------------------------------------------------------------------------------- | +| `median` | The median time of one operation. | +| `alloc` | The Lua memory that one operation allocates. It does not change between runs, so it shows small changes. | +| `main` | `e2e/` only. The main-thread time of one request. Neovim cannot handle a keystroke during this time. | +| `block` | `e2e/` only. The longest uninterrupted main-thread slice of one request. | +| `spawns` | The processes that one operation starts. | +| `blocking` | The synchronous process calls (`vim.fn.system`) of one operation. These stop Neovim until they finish. | + +To run the benchmarks: + +```sh +make bench +make bench BENCH_ARGS="--filter keystroke" +``` + +To compare your changes with `main`: + +```sh +make bench-compare +``` + +`scripts/bench-compare.sh` runs the cases of the working tree on the `lua/` code of both revisions, two runs each, +and keeps the best value of each side. It reports a regression when a time grows by more than 25%, an allocation +grows by more than 10%, or a process count grows. Very small changes do not count. The options of +`benchmarks/compare.lua` change the limits. + +To add a case, add it to the file of its group. A case that starts a process must be an async case that calls `done`. +Use the fixtures in `benchmarks/fixtures.lua` so that the case does not use the network. + ## CI The workflow in `.github/workflows/ci.yml` runs on pushes to `main`, on pull requests, and on manual dispatch. It has these jobs: -| Job | Steps | Blocks | -| ---------------- | --------------------------------------------------------------------------------------------------------- | ------ | -| `Quality` | `make format-check`, `make lint`, and `make typecheck` in the default shell | Yes | -| `Neovim stable` | `make test` in the default shell | Yes | -| `Neovim 0.12.0` | `make test` in the `nvim-0_12_0` shell | Yes | -| `Nightly cache` | `scripts/nightly-cache.sh check` (the locked nightly Neovim is in the cache for each system) | Yes | -| `Neovim nightly` | `make test` with the newest `neovim-nightly-overlay` commit that is in the cache, not the locked revision | No | +| Job | Steps | Blocks | +| ---------------- | ---------------------------------------------------------------------------------------------------------- | ------ | +| `Quality` | `make format-check`, `make lint`, and `make typecheck` in the default shell | Yes | +| `Neovim stable` | `make test` in the default shell | Yes | +| `Neovim 0.12.0` | `make test` in the `nvim-0_12_0` shell | Yes | +| `Nightly cache` | `scripts/nightly-cache.sh check` (the locked nightly Neovim is in the cache for each system) | Yes | +| `Neovim nightly` | `make test` with the newest `neovim-nightly-overlay` commit that is in the cache, not the locked revision | No | +| `Benchmarks` | Pull requests only. `scripts/bench-compare.sh` against the base branch, with the report in the job summary | No | A weekly schedule (Monday 06:00 UTC) runs only the nightly job. GitHub disables scheduled workflows after 60 days without repository activity. To enable the schedule again, use the Actions tab of the repository. diff --git a/Makefile b/Makefile index c4bf8c9..6822879 100644 --- a/Makefile +++ b/Makefile @@ -4,9 +4,10 @@ SELENE ?= selene LUA_LANGUAGE_SERVER ?= lua-language-server MINI_VERSION := v0.18.0 MINI_DIR := deps/mini.nvim -LUA_PATHS := lua tests +LUA_PATHS := lua tests benchmarks +REF ?= main -.PHONY: test-deps test test-file format format-check lint typecheck ci +.PHONY: test-deps test test-file bench bench-compare format format-check lint typecheck ci test-deps: @if [ ! -d "$(MINI_DIR)/.git" ]; then \ @@ -27,6 +28,14 @@ test-file: test-deps TEST_FILE="$(FILE)" $(NVIM) --headless --noplugin -u tests/minimal_init.lua \ -c "lua MiniTest.run_file(vim.env.TEST_FILE)" +# Prints one line for each case. Options go in BENCH_ARGS, for example BENCH_ARGS="--filter keystroke". +bench: + $(NVIM) --headless --clean -l benchmarks/run.lua $(BENCH_ARGS) + +# Compares the benchmarks of REF with the working tree +bench-compare: + NVIM="$(NVIM)" scripts/bench-compare.sh "$(REF)" + format: $(STYLUA) $(LUA_PATHS) diff --git a/README.md b/README.md index 0e7ce26..8091f09 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Git source for [hrsh7th/nvim-cmp](https://github.com/hrsh7th/nvim-cmp) and [Sagh - Neovim >= 0.12 - git (>= 2.31 to find trailer keys in history) -- curl +- curl (>= 7.55) - [GitHub CLI](https://cli.github.com/) (optional, cmp-git uses curl if it is not available) - [GitLab CLI](https://gitlab.com/gitlab-org/cli) (optional, cmp-git uses curl if it is not available) @@ -195,7 +195,7 @@ require("cmp_git").setup({ }, }, gitlab = { - hosts = {}, -- list of private instances of gitlab + hosts = {}, -- list of private instances of gitlab, with the path prefix if any, such as "example.com/gitlab" issues = { limit = 100, state = "opened", -- opened, closed, all @@ -220,7 +220,8 @@ require("cmp_git").setup({ actions = { "git_trailer_keys" }, }, { - pattern = [[\%(^[[:alnum:]-]\+:\s*\)\@<=\%([^[:space:]<>:#@!][^<>:#@!]*\%(<[^>]*\)\?\)\?]], + pattern = [[^[[:alnum:]-]\+:\s*\zs\%([^[:space:]<>:#@!][^<>:#@!]*\%(<[^>]*\)\?\)\?]], + keyword_pattern = [[^[[:alnum:]-]\+\zs:\S*\|^[[:alnum:]-]\+:\s*\zs\%([^[:space:]<>:#@!][^<>:#@!]*\%(<[^>]*\)\?\)\?]], actions = { "git_trailer_identities", "git_trailer_values" }, }, { diff --git a/benchmarks/cases/e2e.lua b/benchmarks/cases/e2e.lua new file mode 100644 index 0000000..64e67a9 --- /dev/null +++ b/benchmarks/cases/e2e.lua @@ -0,0 +1,84 @@ +---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. +local fixtures = require("benchmarks.fixtures") + +local COMMITS = 1000 + +local LINES = { + "Fix the parser", + "", + "See the notes", + "", + "Co-authored-by: Ad", + "Refs: AB", + "Sig", +} + +---@type table +local REQUESTS = { + commits = { row = 3, line = "See :", trigger_character = ":" }, + trailer_keys = { row = 7, line = "Sig" }, + trailer_identities = { row = 5, line = "Co-authored-by: Ad" }, + trailer_values = { row = 6, line = "Refs: AB" }, + github_issues_and_prs = { row = 3, line = "See #", trigger_character = "#" }, + github_mentions = { row = 3, line = "See @", trigger_character = "@" }, +} + +---@param name string A key of `REQUESTS`. +---@param cold boolean +---@return bench.AsyncCase +local function request_case(name, cold) + local request = REQUESTS[name] + return { + name = string.format("e2e/%s/%s", name, cold and "cold" or "warm"), + async = true, + setup = function() + local dir = fixtures.repo(COMMITS) + return { + bufnr = fixtures.commit_buffer(dir, LINES), + core = require("cmp_git.core").new({}), + } + end, + before_each = function(state) + vim.api.nvim_set_current_buf(state.bufnr) + if cold then + state.core = require("cmp_git.core").new({}) + end + end, + run = function(state, done) + state.core:complete({ + trigger_character = request.trigger_character, + cursor = { row = request.row, character = #request.line }, + line = request.line, + bufnr = state.bufnr, + }, function(list) + if list.isIncomplete then + return + end + if #list.items == 0 then + done("the request gave no items") + return + end + done() + end) + end, + } +end + +---@type bench.Case[] +local cases = {} +for _, name in ipairs({ + "commits", + "trailer_keys", + "trailer_identities", + "trailer_values", + "github_issues_and_prs", + "github_mentions", +}) do + table.insert(cases, request_case(name, true)) + table.insert(cases, request_case(name, false)) +end + +return cases diff --git a/benchmarks/cases/keystroke.lua b/benchmarks/cases/keystroke.lua new file mode 100644 index 0000000..5912f6a --- /dev/null +++ b/benchmarks/cases/keystroke.lua @@ -0,0 +1,103 @@ +---Work that runs while the user types: the checks that nvim-cmp and blink.cmp make on each keystroke, and requests +---that cmp-git declines without starting a process. +local fixtures = require("benchmarks.fixtures") + +local LINES = { + "Fix the parser", + "", + "Body text", + "", + "Co-authored-by: Ada Lovelace ", +} + +local function noop() end + +---nvim-cmp finds the keyword start on each keystroke with the source's keyword pattern. It compiles the regex once +---(`cmp.utils.pattern.offset`). +---@param line string +---@return bench.SyncCase +local function keyword_offset(name, line) + return { + name = "keystroke/keyword_offset/" .. name, + setup = function() + local core = require("cmp_git.core").new({}) + return { regex = vim.regex([[\%(]] .. core:get_keyword_pattern() .. [[\)\m$]]), line = line } + end, + run = function(state) + state.regex:match_str(state.line) + end, + } +end + +---An nvim-cmp request from `cmp.Source.complete`. +---@param line string +---@param row integer +---@return cmp_git.CmpCompletionParams +local function cmp_params(line, row) + return { + context = { + cursor_before_line = line, + cursor = { row = row, col = #line + 1, character = #line }, + }, + completion_context = { triggerKind = 1 }, + } +end + +---@type bench.Case[] +return { + { + name = "keystroke/is_available", + setup = function() + fixtures.scratch_buffer(LINES) + return require("cmp_git.core").new({}) + end, + run = function(core) + core:is_available() + end, + }, + keyword_offset("prose_80", fixtures.prose(80)), + keyword_offset("prose_300", fixtures.prose(300)), + keyword_offset("prose_1000", fixtures.prose(1000)), + keyword_offset("trailer_value", "Co-authored-by: Ada Lov"), + { + -- The first word of the subject matches the trailer key pattern, but trailers start below the subject + name = "keystroke/nvim_cmp/complete_declined", + setup = function() + fixtures.scratch_buffer(LINES) + return { source = require("cmp_git.source").new({}), params = cmp_params("Fix", 1) } + end, + run = function(state) + state.source:complete(state.params, noop) + end, + }, + { + name = "keystroke/nvim_cmp/complete_declined_prose_300", + setup = function() + local line = fixtures.prose(300) + fixtures.scratch_buffer({ "Fix the parser", "", line }) + return { source = require("cmp_git.source").new({}), params = cmp_params(line, 3) } + end, + run = function(state) + state.source:complete(state.params, noop) + end, + }, + { + name = "keystroke/blink/get_completions_declined", + setup = function() + local bufnr = fixtures.scratch_buffer(LINES) + return { + source = require("cmp_git.blink").new({}), + ctx = { + bufnr = bufnr, + line = "Fix the parser", + cursor = { 1, 3 }, + trigger = { kind = "keyword" }, + }, + } + end, + run = function(state) + local cancel = state.source:get_completions(state.ctx, noop) + cancel() + end, + }, +} diff --git a/benchmarks/cases/request.lua b/benchmarks/cases/request.lua new file mode 100644 index 0000000..7dd01f0 --- /dev/null +++ b/benchmarks/cases/request.lua @@ -0,0 +1,246 @@ +---Work that runs on the main thread for each completion request: routing, parsing command output, and making the +---completion items. Cache hits make the items again, so the `items` cases are the cost of a cached request. +local fixtures = require("benchmarks.fixtures") + +local COMMITS = 1000 + +---Remove the cmp-git modules from the module cache, so that the next `require` loads them again. +local function unload() + for name in pairs(package.loaded) do + if name == "cmp_git" or name:find("^cmp_git%.") then + package.loaded[name] = nil + end + end +end + +---@type bench.Case[] +return { + { + name = "request/route/trailer_key", + setup = function() + local bufnr = fixtures.scratch_buffer({ "Fix the parser", "", "Body text", "", "Sig" }) + return { + core = require("cmp_git.core").new({}), + context = { bufnr = bufnr, cursor = { row = 5, character = 3 }, line = "Sig" }, + } + end, + run = function(state) + state.core:_route(state.context) + end, + }, + { + name = "request/route/trailer_value", + setup = function() + local bufnr = fixtures.scratch_buffer({ "Fix the parser", "", "Body text", "", "Co-authored-by: Ad" }) + return { + core = require("cmp_git.core").new({}), + context = { bufnr = bufnr, cursor = { row = 5, character = 18 }, line = "Co-authored-by: Ad" }, + } + end, + run = function(state) + state.core:_route(state.context) + end, + }, + { + name = "request/commits/parse_1000", + setup = function() + return { + output = fixtures.commits_log(fixtures.commits(COMMITS)), + config = require("cmp_git.config").normalize({}).git.commits, + } + end, + run = function(state) + require("cmp_git.provider.capabilities.commits").parse(state.output, state.config) + end, + }, + { + name = "request/commits/items_100", + setup = function() + local commits = require("cmp_git.provider.capabilities.commits") + local config = require("cmp_git.config").normalize({}).git.commits + return { + commits = commits, + config = config, + parsed = commits.parse(fixtures.commits_log(fixtures.commits(100)), config), + } + end, + run = function(state) + state.commits.items(state.parsed, state.config, ":") + end, + }, + { + name = "request/trailers/keys_parse_100", + setup = function() + return { + output = fixtures.trailer_keys_log(fixtures.commits(100)), + config = require("cmp_git.config").normalize({}).git.trailers.keys, + } + end, + run = function(state) + require("cmp_git.provider.capabilities.trailers").keys.parse(state.output, state.config) + end, + }, + { + name = "request/trailers/identities_parse_1000", + setup = function() + return { output = fixtures.trailer_identities_log(fixtures.commits(COMMITS)) } + end, + run = function(state) + require("cmp_git.provider.capabilities.trailers").identities.parse( + state.output, + "Bench User 1700000000 +0000\n" + ) + end, + }, + { + name = "request/trailers/identities_items_1000", + setup = function() + local trailers = require("cmp_git.provider.capabilities.trailers") + return { + trailers = trailers, + config = require("cmp_git.config").normalize({}).git.trailers.identities, + parsed = trailers.identities.parse(fixtures.trailer_identities_log(fixtures.commits(COMMITS)), ""), + } + end, + run = function(state) + state.trailers.identities.items(state.parsed, state.config, nil) + end, + }, + { + name = "request/trailers/values_parse_1000", + setup = function() + return { output = fixtures.trailer_values_log(fixtures.commits(COMMITS)) } + end, + run = function(state) + require("cmp_git.provider.capabilities.trailers").values.parse(state.output) + end, + }, + { + name = "request/github/issues_100", + setup = function() + local config = require("cmp_git.config").normalize({}).github.issues + local git_info = { host = "github.com", owner = "owner", repo = "repo" } + return { + json = fixtures.github_issues_json(100), + request = require("cmp_git.provider.adapters.github").issues_request(git_info, "#", config), + } + end, + run = function(state) + require("cmp_git.response").completion_list( + state.json, + state.request.handle_item, + state.request.handle_parsed + ) + end, + }, + { + name = "request/github/mentions_100", + setup = function() + local config = require("cmp_git.config").normalize({}).github.mentions + local git_info = { host = "github.com", owner = "owner", repo = "repo" } + local adapter = require("cmp_git.provider.adapters.github") + return { + json = fixtures.github_mentions_json(100), + request = adapter.mentions_request(git_info, "@", config, { page = 1, page_size = 100 }), + } + end, + run = function(state) + require("cmp_git.response").completion_list( + state.json, + state.request.handle_item, + state.request.handle_parsed + ) + end, + }, + { + name = "request/gitlab/merge_requests_100", + setup = function() + local config = require("cmp_git.config").normalize({}).gitlab.merge_requests + local git_info = { host = "gitlab.com", owner = "owner", repo = "repo" } + return { + json = fixtures.gitlab_merge_requests_json(100), + request = require("cmp_git.provider.adapters.gitlab").change_requests_request(git_info, "!", config), + } + end, + run = function(state) + require("cmp_git.response").completion_list( + state.json, + state.request.handle_item, + state.request.handle_parsed + ) + end, + }, + { + name = "request/text_edits_100", + setup = function() + local list = require("cmp_git.response").completion_list(fixtures.github_issues_json(100), function(item) + return { label = "#" .. item.number, insertText = "#" .. item.number } + end) + return { + apply = require("cmp_git.core").apply_text_edits, + items = list.items, + context = { cursor = { row = 3, character = 7 }, line = "See #12", trigger_character = "#" }, + } + end, + run = function(state) + state.apply(state.items, state.context) + end, + }, + { + name = "startup/require", + run = function() + unload() + require("cmp_git.source") + require("cmp_git.blink") + end, + }, + { + -- What `setup` costs: the modules of the nvim-cmp source and a new source + name = "startup/setup", + run = function() + unload() + require("cmp_git.source").new({}) + end, + }, + { + -- All the code, also the modules that load on the first request that needs them + name = "startup/require_all", + setup = function() + local modules = {} + for _, path in ipairs(vim.api.nvim_get_runtime_file("lua/cmp_git/**/*.lua", true)) do + local name = path:match("/lua/(cmp_git/.*)%.lua$"):gsub("/init$", ""):gsub("/", ".") + if not vim.tbl_contains(modules, name) then + table.insert(modules, name) + end + end + return { modules = modules } + end, + run = function(state) + unload() + for _, name in ipairs(state.modules) do + require(name) + end + end, + }, + { + name = "startup/new_source", + run = function() + require("cmp_git.source").new({}) + end, + }, + { + name = "startup/new_source_with_overrides", + run = function() + require("cmp_git.source").new({ + filetypes = { "gitcommit", "markdown" }, + github = { hosts = { "github.example.com" }, issues = { limit = 50 } }, + git = { commits = { limit = 200 } }, + }) + end, + }, + { + -- The overhead of the measurement loop. Results near this value are at the resolution of the harness. + name = "harness/noop", + run = function() end, + }, +} diff --git a/benchmarks/compare.lua b/benchmarks/compare.lua new file mode 100644 index 0000000..54b053e --- /dev/null +++ b/benchmarks/compare.lua @@ -0,0 +1,310 @@ +-- Compares two sets of benchmark results from benchmarks/run.lua and exits with 1 when the head regressed. +-- +-- nvim --headless --clean -l benchmarks/compare.lua BASE HEAD [options] +-- +-- BASE and HEAD are result files. To lower the noise, give the files of more than one run as a comma-separated list. +-- Each side then uses the lowest median of its runs. +-- +-- Options: +-- --markdown FILE Also append the report as a Markdown table to FILE, for example $GITHUB_STEP_SUMMARY. +-- --time-threshold R A time regresses when it grows by more than this ratio (default 0.25). +-- --alloc-threshold R An allocation regresses when it grows by more than this ratio (default 0.10). +-- +-- A time must also grow by more than 0.5 us (sync cases) or 2 ms (async cases), and an allocation by more than +-- 0.5 KB, so that the noise of very small values does not count. Any increase of the process counts regresses. + +local TIME_FLOOR = { us = 0.5, ms = 2 } +local ALLOC_FLOOR_KB = 0.5 + +---@param argv string[] +local function parse_args(argv) + local opts = { files = {}, time_threshold = 0.25, alloc_threshold = 0.10 } + local i = 1 + while i <= #argv do + local name = argv[i] + local function value() + i = i + 1 + return argv[i] or error("missing value for " .. name, 0) + end + + if name == "--markdown" then + opts.markdown = value() + elseif name == "--time-threshold" then + opts.time_threshold = assert(tonumber(value()), "--time-threshold needs a number") + elseif name == "--alloc-threshold" then + opts.alloc_threshold = assert(tonumber(value()), "--alloc-threshold needs a number") + elseif name:sub(1, 2) == "--" then + error("unknown option " .. name, 0) + else + table.insert(opts.files, name) + end + i = i + 1 + end + if #opts.files ~= 2 then + error("usage: compare.lua BASE HEAD [--markdown FILE] [--time-threshold R] [--alloc-threshold R]", 0) + end + return opts +end + +---@class bench.Side +---@field order string[] +---@field results table +---@field errors table +---@field meta table + +---Read the runs of one side and keep the best value of each metric. +---@param list string Comma-separated result files. +---@return bench.Side +local function load_side(list) + local side = { order = {}, results = {}, errors = {}, meta = {} } + for path in list:gmatch("[^,]+") do + local file = assert(io.open(path, "r"), "cannot read " .. path) + local data = vim.json.decode(file:read("*a")) + file:close() + + side.meta = data.meta + for _, name in ipairs(data.order) do + if not vim.tbl_contains(side.order, name) then + table.insert(side.order, name) + end + end + for name, message in pairs(data.errors or {}) do + side.errors[name] = message + end + for name, result in pairs(data.results) do + local best = side.results[name] + if not best then + side.results[name] = result + else + best.median = math.min(best.median, result.median) + best.min = math.min(best.min, result.min) + if result.alloc_kb then + best.alloc_kb = math.min(best.alloc_kb, result.alloc_kb) + end + if result.main_ms then + best.main_ms = math.min(best.main_ms, result.main_ms) + best.block_ms = math.min(best.block_ms, result.block_ms) + end + -- Process counts do not depend on the machine, so a difference between runs is real + best.spawns = math.max(best.spawns, result.spawns) + best.blocking = math.max(best.blocking, result.blocking) + end + end + end + return side +end + +---@param base number +---@param head number +---@param threshold number +---@param floor number +---@return "regressed" | "improved" | nil +local function classify(base, head, threshold, floor) + if math.abs(head - base) <= floor then + return nil + end + if head > base * (1 + threshold) then + return "regressed" + end + if head < base / (1 + threshold) then + return "improved" + end + return nil +end + +---@param value number? +---@param unit "us" | "ms" +local function format_time(value, unit) + if value == nil then + return "-" + end + if unit == "ms" then + return string.format("%.2f ms", value) + end + if value >= 1000 then + return string.format("%.2f ms", value / 1000) + end + return string.format("%.2f us", value) +end + +---@param base number? +---@param head number? +local function format_change(base, head) + if not base or not head or base == 0 then + return "-" + end + return string.format("%+.0f%%", (head / base - 1) * 100) +end + +local function main() + -- `nvim -l` gives the script arguments in the global `arg` + -- selene: allow(global_usage) + local opts = parse_args(_G.arg) + local base = load_side(opts.files[1]) + local head = load_side(opts.files[2]) + + local names = vim.list_extend({}, head.order) + for _, name in ipairs(base.order) do + if not vim.tbl_contains(names, name) then + table.insert(names, name) + end + end + + local rows = {} + local regressions = 0 + for _, name in ipairs(names) do + local b, h = base.results[name], head.results[name] + local notes = {} + local status = "ok" + + if head.errors[name] then + status = base.errors[name] and "error in both" or "error" + if not base.errors[name] then + regressions = regressions + 1 + end + elseif not h then + status = "removed" + elseif not b then + status = base.errors[name] and "base error" or "new" + else + local unit = h.unit + local changes = { + { "time", classify(b.median, h.median, opts.time_threshold, TIME_FLOOR[unit]) }, + } + if h.main_ms and b.main_ms then + table.insert(changes, { + "main thread", + classify(b.main_ms, h.main_ms, opts.time_threshold, TIME_FLOOR.ms), + }) + end + if h.alloc_kb and b.alloc_kb then + table.insert(changes, { + "alloc", + classify(b.alloc_kb, h.alloc_kb, opts.alloc_threshold, ALLOC_FLOOR_KB), + }) + end + if h.spawns > b.spawns + 0.01 then + table.insert(changes, { "spawns", "regressed" }) + elseif h.spawns < b.spawns - 0.01 then + table.insert(changes, { "spawns", "improved" }) + end + if h.blocking > b.blocking + 0.01 then + table.insert(changes, { "blocking calls", "regressed" }) + elseif h.blocking < b.blocking - 0.01 then + table.insert(changes, { "blocking calls", "improved" }) + end + + local regressed, improved = {}, {} + for _, change in ipairs(changes) do + if change[2] == "regressed" then + table.insert(regressed, change[1]) + elseif change[2] == "improved" then + table.insert(improved, change[1]) + end + end + if #regressed > 0 then + status = "REGRESSED" + regressions = regressions + 1 + table.insert(notes, "worse: " .. table.concat(regressed, ", ")) + end + if #improved > 0 then + if status == "ok" then + status = "improved" + end + table.insert(notes, "better: " .. table.concat(improved, ", ")) + end + end + + local unit = (h or b or { unit = "us" }).unit + table.insert(rows, { + name = name, + base = format_time(b and b.median, unit), + head = format_time(h and h.median, unit), + change = format_change(b and b.median, h and h.median), + alloc = (b and b.alloc_kb and h and h.alloc_kb) + and string.format("%.1f -> %.1f KB", b.alloc_kb, h.alloc_kb) + or "-", + spawns = (b and h) and string.format("%g -> %g", b.spawns, h.spawns) or "-", + status = status .. (#notes > 0 and " (" .. table.concat(notes, "; ") .. ")" or ""), + }) + end + + local header = string.format( + "base %s (%s), head %s (%s), Neovim %s", + tostring(base.meta.revision), + tostring(base.meta.date), + tostring(head.meta.revision), + tostring(head.meta.date), + tostring(head.meta.nvim) + ) + print(header) + print( + string.format( + "%-52s %12s %12s %7s %22s %12s %s", + "case", + "base", + "head", + "change", + "alloc/op", + "spawns/op", + "status" + ) + ) + for _, row in ipairs(rows) do + print( + string.format( + "%-52s %12s %12s %7s %22s %12s %s", + row.name, + row.base, + row.head, + row.change, + row.alloc, + row.spawns, + row.status + ) + ) + end + print(string.format("%d regression(s)", regressions)) + + if opts.markdown then + local lines = { + "## Benchmarks", + "", + header, + "", + "| Case | Base | Head | Change | Alloc/op | Spawns/op | Status |", + "| --- | ---: | ---: | ---: | ---: | ---: | --- |", + } + for _, row in ipairs(rows) do + table.insert( + lines, + string.format( + "| `%s` | %s | %s | %s | %s | %s | %s |", + row.name, + row.base, + row.head, + row.change, + row.alloc, + row.spawns, + row.status + ) + ) + end + table.insert(lines, "") + table.insert(lines, string.format("%d regression(s)", regressions)) + table.insert(lines, "") + local file = assert(io.open(opts.markdown, "a")) + file:write(table.concat(lines, "\n")) + file:close() + end + + return regressions == 0 and 0 or 1 +end + +local ok, code = xpcall(main, debug.traceback) +if not ok then + -- selene: allow(incorrect_standard_library_use) + io.stderr:write(tostring(code) .. "\n") + os.exit(2) +end +os.exit(code) diff --git a/benchmarks/fixtures.lua b/benchmarks/fixtures.lua new file mode 100644 index 0000000..0f71a6b --- /dev/null +++ b/benchmarks/fixtures.lua @@ -0,0 +1,408 @@ +---Deterministic data for the benchmarks: commits, the output of the git commands that cmp-git runs, API responses, +---and a git repository with a fake `gh`. Nothing here uses the network. +local M = {} + +local US = "\031" +local NUL = "\0" + +---@class bench.Person +---@field name string +---@field email string + +---@param prefix string +---@param count integer +---@return bench.Person[] +local function people(prefix, count) + local result = {} + for i = 1, count do + table.insert(result, { + name = string.format("%s %d", prefix, i), + email = string.format("%s.%d@example.com", prefix:lower(), i), + }) + end + return result +end + +local AUTHORS = people("Author", 40) +local COAUTHORS = people("Contributor", 300) + +local WORDS = { + "parser", + "cache", + "request", + "trailer", + "remote", + "buffer", + "provider", + "format", + "history", + "option", + "window", + "message", +} + +---@class bench.Commit +---@field sha string +---@field subject string +---@field body string The body that `%b` prints, with the trailer block. +---@field author bench.Person +---@field timestamp integer +---@field trailers { key: string, value: string }[] + +---@param count integer +---@return bench.Commit[] commits Newest first, like `git log`. +function M.commits(count) + local commits = {} + for i = 1, count do + local word = WORDS[i % #WORDS + 1] + local author = AUTHORS[i % #AUTHORS + 1] + local trailers = {} + if i % 3 == 0 then + local coauthor = COAUTHORS[(i * 7) % #COAUTHORS + 1] + table.insert(trailers, { key = "Co-authored-by", value = coauthor.name .. " <" .. coauthor.email .. ">" }) + end + if i % 5 == 0 then + table.insert(trailers, { key = "Fixes", value = "#" .. (1000 + i) }) + end + if i % 7 == 0 then + table.insert(trailers, { key = "Refs", value = "ABC-" .. (i % 50) }) + end + if i % 11 == 0 then + local reviewer = COAUTHORS[(i * 13) % #COAUTHORS + 1] + table.insert(trailers, { key = "Reviewed-by", value = reviewer.name .. " <" .. reviewer.email .. ">" }) + end + if i % 13 == 0 then + table.insert(trailers, { key = "Assisted-by", value = "Tool " .. (i % 3) }) + end + table.insert(trailers, { key = "Signed-off-by", value = author.name .. " <" .. author.email .. ">" }) + + local trailer_lines = {} + for _, trailer in ipairs(trailers) do + table.insert(trailer_lines, trailer.key .. ": " .. trailer.value) + end + + local body = table.concat({ + string.format("Change how the %s handles the case that commit %d found. The old code read the", word, i), + string.format("whole %s each time, so it was slow when the history was long.", word), + "", + table.concat(trailer_lines, "\n"), + }, "\n") .. "\n" + + table.insert(commits, { + sha = string.rep(string.format("%08x", i * 2654435761 % 4294967296), 5), + subject = string.format("fix(%s): handle the case from commit %d", word, i), + body = body, + author = author, + timestamp = 1700000000 - i * 3600, + trailers = trailers, + }) + end + return commits +end + +---The output of the `git log` command in `provider/capabilities/commits.lua`. `--pretty=format:` puts a newline +---between entries. +---@param commits bench.Commit[] +---@return string +function M.commits_log(commits) + local entries = {} + for _, commit in ipairs(commits) do + table.insert( + entries, + table.concat({ + commit.sha, + commit.subject, + commit.body, + commit.author.name, + commit.author.email, + tostring(commit.timestamp), + "", + }, NUL) + ) + end + return table.concat(entries, "\n") +end + +---The output of `trailers.keys.request`. `--format=` ends each entry with a newline. +---@param commits bench.Commit[] +---@return string +function M.trailer_keys_log(commits) + local entries = {} + for _, commit in ipairs(commits) do + local keys = {} + for _, trailer in ipairs(commit.trailers) do + table.insert(keys, trailer.key) + end + table.insert(entries, table.concat(keys, US) .. NUL .. "\n") + end + return table.concat(entries) +end + +---The output of `trailers.identities.request` for the default identity keys. +---@param commits bench.Commit[] +---@return string +function M.trailer_identities_log(commits) + local identity_keys = { ["Co-authored-by"] = true, ["Reviewed-by"] = true, ["Signed-off-by"] = true } + local entries = {} + for _, commit in ipairs(commits) do + local fields = { tostring(commit.timestamp), commit.author.name, commit.author.email } + local trailers = {} + for _, trailer in ipairs(commit.trailers) do + if identity_keys[trailer.key] then + table.insert(trailers, trailer.key .. ": " .. trailer.value) + end + end + table.insert(fields, table.concat(trailers, US)) + table.insert(entries, table.concat(fields, US) .. NUL .. "\n") + end + return table.concat(entries) +end + +---The output of `trailers.values.request`. +---@param commits bench.Commit[] +---@return string +function M.trailer_values_log(commits) + local entries = {} + for _, commit in ipairs(commits) do + local fields = { tostring(commit.timestamp) } + for _, trailer in ipairs(commit.trailers) do + table.insert(fields, trailer.key .. ": " .. trailer.value) + end + table.insert(entries, table.concat(fields, US) .. NUL .. "\n") + end + return table.concat(entries) +end + +---@param i integer +---@return string +local function issue_body(i) + local lines = {} + for line = 1, 8 do + table.insert( + lines, + string.format("Line %d of issue %d. The %s fails when the input is long.", line, i, WORDS[line]) + ) + end + return table.concat(lines, "\r\n") +end + +---@param i integer +---@return string +local function iso_date(i) + return string.format("2024-%02d-%02dT%02d:04:05Z", i % 12 + 1, i % 28 + 1, i % 24) +end + +---`gh issue list --json` or `gh pr list --json` output with the default fields. +---@param count integer +---@return string +function M.github_issues_json(count) + local issues = {} + for i = 1, count do + table.insert(issues, { + number = 2000 + i, + title = string.format("The %s fails when the input is long (%d)", WORDS[i % #WORDS + 1], i), + body = issue_body(i), + updatedAt = iso_date(i), + state = "OPEN", + }) + end + return vim.json.encode(issues) +end + +---@param count integer +---@return string +function M.github_mentions_json(count) + local users = {} + for i = 1, count do + table.insert(users, { login = string.format("user-%d", i), id = i, type = "User" }) + end + return vim.json.encode(users) +end + +---GitLab merge requests API output. +---@param count integer +---@return string +function M.gitlab_merge_requests_json(count) + local merge_requests = {} + for i = 1, count do + table.insert(merge_requests, { + iid = 300 + i, + title = string.format("Change the %s (%d)", WORDS[i % #WORDS + 1], i), + description = issue_body(i), + updated_at = iso_date(i), + state = "opened", + }) + end + return vim.json.encode(merge_requests) +end + +---@param length integer +---@return string +function M.prose(length) + local words = {} + local size = 0 + local i = 0 + while size < length do + i = i + 1 + local word = WORDS[i % #WORDS + 1] + table.insert(words, word) + size = size + #word + 1 + end + return table.concat(words, " "):sub(1, length) +end + +local temp_root = nil ---@type string? + +---The temporary directory of this run. `cleanup` deletes it. +---@return string +function M.temp_root() + if not temp_root then + temp_root = vim.fn.tempname() + vim.fn.mkdir(temp_root, "p") + temp_root = assert(vim.uv.fs_realpath(temp_root)) + end + return temp_root +end + +function M.cleanup() + if temp_root then + vim.fn.delete(temp_root, "rf") + temp_root = nil + end +end + +---Run git and stop the benchmark run when it fails. +---@param args string[] +---@param opts? { stdin?: string } +---@return string stdout +local function git(args, opts) + local result = vim.system(vim.list_extend({ "git" }, args), { text = true, stdin = opts and opts.stdin }):wait() + if result.code ~= 0 then + error(string.format("git %s failed: %s", table.concat(args, " "), result.stderr), 0) + end + return result.stdout +end + +---Set the environment that the fixture repository and the fake `gh` need. Git does not read the user's config, and +---the `gh` in the fixture directory comes first in `PATH`. +function M.isolate_environment() + local root = M.temp_root() + local home = root .. "/home" + local bin = root .. "/bin" + local data = root .. "/data" + vim.fn.mkdir(home, "p") + vim.fn.mkdir(bin, "p") + vim.fn.mkdir(data, "p") + + vim.env.HOME = home + vim.env.XDG_CONFIG_HOME = home .. "/.config" + vim.env.GIT_CONFIG_GLOBAL = "/dev/null" + vim.env.GIT_CONFIG_NOSYSTEM = "1" + vim.env.GIT_CEILING_DIRECTORIES = root + vim.env.GITHUB_API_TOKEN = nil + vim.env.GITLAB_TOKEN = nil + vim.env.CMP_GIT_BENCH_DATA = data + vim.env.PATH = bin .. ":" .. vim.env.PATH + vim.g.cmp_git_debug = nil + + local function write(path, content) + local file = assert(io.open(path, "wb")) + file:write(content) + file:close() + end + + write(data .. "/github_issues.json", M.github_issues_json(100)) + write(data .. "/github_pull_requests.json", M.github_issues_json(40)) + write(data .. "/github_mentions.json", M.github_mentions_json(30)) + -- `gh repo set-default --view` fails, as when no default repository is set + write( + bin .. "/gh", + table.concat({ + "#!/bin/sh", + 'case "$1 $2" in', + '"issue list") cat "$CMP_GIT_BENCH_DATA/github_issues.json" ;;', + '"pr list") cat "$CMP_GIT_BENCH_DATA/github_pull_requests.json" ;;', + '"api "*)', + ' case "$2" in', + " */permission) printf '%s' '{\"permission\":\"write\"}' ;;", + ' *"&page=1") cat "$CMP_GIT_BENCH_DATA/github_mentions.json" ;;', + " *) printf '%s' '[]' ;;", + " esac", + " ;;", + "*) exit 1 ;;", + "esac", + "", + }, "\n") + ) + vim.uv.fs_chmod(bin .. "/gh", tonumber("755", 8)) +end + +local repo_dir = nil ---@type string? + +---A git repository with `count` commits from `M.commits` and a GitHub `origin`. It is made once for each run. +---@param count integer +---@return string dir +function M.repo(count) + if repo_dir then + return repo_dir + end + + local dir = M.temp_root() .. "/repo" + vim.fn.mkdir(dir, "p") + git({ "-C", dir, "-c", "init.defaultBranch=main", "init", "-q" }) + git({ "-C", dir, "remote", "add", "origin", "https://github.com/owner/repo.git" }) + + -- fast-import makes the history in one process. The commits have empty trees. + local commits = M.commits(count) + local stream = {} + for i = #commits, 1, -1 do + local commit = commits[i] + local message = commit.subject .. "\n\n" .. commit.body + local ident = string.format("%s <%s> %d +0000", commit.author.name, commit.author.email, commit.timestamp) + table.insert(stream, "commit refs/heads/main") + table.insert(stream, "mark :" .. i) + table.insert(stream, "author " .. ident) + table.insert(stream, "committer " .. ident) + table.insert(stream, "data " .. #message) + table.insert(stream, message) + if i < #commits then + table.insert(stream, "from :" .. (i + 1)) + end + table.insert(stream, "") + end + git({ "-C", dir, "fast-import", "--quiet" }, { stdin = table.concat(stream, "\n") }) + git({ "-C", dir, "config", "user.name", "Bench User" }) + git({ "-C", dir, "config", "user.email", "bench@example.com" }) + + repo_dir = dir + return dir +end + +---A commit message buffer in `.git` of the repository, as git opens it. It becomes the current buffer, because +---cmp-git reads the cwd and the filetype from the current buffer. +---@param dir string The repository. +---@param lines string[] +---@return integer bufnr +function M.commit_buffer(dir, lines) + local bufnr = vim.fn.bufadd(dir .. "/.git/COMMIT_EDITMSG") + vim.fn.bufload(bufnr) + vim.bo[bufnr].buftype = "nofile" + vim.bo[bufnr].swapfile = false + vim.bo[bufnr].filetype = "gitcommit" + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + vim.api.nvim_set_current_buf(bufnr) + return bufnr +end + +---A scratch commit message buffer for cases that start no process. It becomes the current buffer. +---@param lines string[] +---@return integer bufnr +function M.scratch_buffer(lines) + local bufnr = vim.api.nvim_create_buf(true, true) + vim.bo[bufnr].filetype = "gitcommit" + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + vim.api.nvim_set_current_buf(bufnr) + return bufnr +end + +return M diff --git a/benchmarks/harness.lua b/benchmarks/harness.lua new file mode 100644 index 0000000..e61942a --- /dev/null +++ b/benchmarks/harness.lua @@ -0,0 +1,344 @@ +---Measurement functions for the cases in `benchmarks/cases/`. +local M = {} + +local hrtime = vim.uv.hrtime + +---A case whose operation finishes before `run` returns. +---@class bench.SyncCase +---@field name string +---@field setup? fun(): any Runs once. Its result goes to `run` and `teardown`. +---@field run fun(state: any) One operation. +---@field teardown? fun(state: any) + +---A case whose operation finishes when it calls `done`, for example a completion request that starts processes. +---@class bench.AsyncCase +---@field name string +---@field async true +---@field setup? fun(): any Runs once. Its result goes to the other functions. +---@field before_each? fun(state: any) Runs before each operation, outside the measurement. +---@field run fun(state: any, done: fun(err?: string)) One operation. An error message fails the case. +---@field teardown? fun(state: any) + +---@alias bench.Case bench.SyncCase | bench.AsyncCase + +---@class bench.Options +---@field samples integer Samples for each sync case. +---@field async_samples integer Samples for each async case. +---@field min_sample_ns number A sync sample repeats the operation until it takes at least this long. +---@field alloc_ops integer Operations in the allocation pass of a sync case. +---@field timeout_ms integer Time limit for one async operation. + +---@class bench.Result +---@field kind "sync" | "async" +---@field unit "us" | "ms" +---@field samples number[] +---@field median number +---@field min number +---@field alloc_kb? number Lua heap allocated for each operation of a sync case. +---@field main_ms? number Median main-thread time of an async operation. +---@field block_ms? number Median of the longest uninterrupted main-thread slice of an async operation. +---@field spawns number Processes started for each operation. +---@field blocking number Synchronous process calls (`vim.fn.system`) for each operation. + +---@param values number[] +---@return number +local function median(values) + local sorted = vim.list_extend({}, values) + table.sort(sorted) + local middle = math.floor(#sorted / 2) + 1 + if #sorted % 2 == 1 then + return sorted[middle] + end + return (sorted[middle - 1] + sorted[middle]) / 2 +end + +---@param values number[] +---@return number +local function minimum(values) + return math.min(unpack(values)) +end + +M.median = median + +---Counts the processes that the code under test starts, and times its main-thread work. The code reads +---`vim.system`, `vim.fn.system`, and `vim.schedule` when it calls them, so replacing them here is enough. +local probe = { + spawns = 0, + blocking = 0, + pending = 0, + slices = {}, ---@type number[] +} + +local originals = nil ---@type { system: function, fn_system: function, fn_systemlist: function, schedule: function }? + +function probe.reset() + probe.spawns = 0 + probe.blocking = 0 + probe.slices = {} +end + +function probe.install() + originals = { + system = vim.system, + fn_system = vim.fn.system, + fn_systemlist = vim.fn.systemlist, + schedule = vim.schedule, + } + local o = originals + + ---@diagnostic disable-next-line: duplicate-set-field + vim.system = function(cmd, opts, on_exit) + probe.spawns = probe.spawns + 1 + if not on_exit then + return o.system(cmd, opts) + end + + probe.pending = probe.pending + 1 + return o.system(cmd, opts, function(...) + probe.pending = probe.pending - 1 + return on_exit(...) + end) + end + + local function blocking(original) + return function(...) + probe.spawns = probe.spawns + 1 + probe.blocking = probe.blocking + 1 + return original(...) + end + end + vim.fn.system = blocking(o.fn_system) + vim.fn.systemlist = blocking(o.fn_systemlist) + + ---@diagnostic disable-next-line: duplicate-set-field + vim.schedule = function(fn) + return o.schedule(function() + local start = hrtime() + fn() + table.insert(probe.slices, hrtime() - start) + end) + end +end + +function probe.uninstall() + if not originals then + return + end + + vim.system = originals.system + vim.fn.system = originals.fn_system + vim.fn.systemlist = originals.fn_systemlist + vim.schedule = originals.schedule + originals = nil +end + +---Wait until the processes that the code started exit and their scheduled callbacks run, so that the work of one +---operation does not run during the next one. +---@param timeout_ms integer +local function settle(timeout_ms) + vim.wait(timeout_ms, function() + return probe.pending == 0 + end, 1) + for _ = 1, 3 do + vim.wait(0) + end +end + +---@param run fun(state: any) +---@param state any +---@param iterations integer +---@return number ns Time for each operation. +local function time_batch(run, state, iterations) + local start = hrtime() + for _ = 1, iterations do + run(state) + end + return (hrtime() - start) / iterations +end + +---@param case bench.SyncCase +---@param opts bench.Options +---@return bench.Result +function M.measure_sync(case, opts) + local state = case.setup and case.setup() + + probe.install() + local ok, result = pcall(function() + -- The first runs warm up the JIT compiler and the caches that the case fills + probe.reset() + local per_op = time_batch(case.run, state, 1) + local spawns, blocking = probe.spawns, probe.blocking + settle(opts.timeout_ms) + + local iterations = 1 + while per_op * iterations < opts.min_sample_ns and iterations < 2 ^ 20 do + iterations = iterations * 2 + per_op = time_batch(case.run, state, iterations) + end + + local samples = {} + for i = 1, opts.samples do + samples[i] = time_batch(case.run, state, iterations) / 1e3 + end + + -- With the collector stopped, the heap grows by exactly the bytes that the operations allocate + collectgarbage("collect") + collectgarbage("stop") + local before = collectgarbage("count") + for _ = 1, opts.alloc_ops do + case.run(state) + end + local alloc_kb = (collectgarbage("count") - before) / opts.alloc_ops + collectgarbage("restart") + + ---@type bench.Result + return { + kind = "sync", + unit = "us", + samples = samples, + median = median(samples), + min = minimum(samples), + alloc_kb = alloc_kb, + spawns = spawns, + blocking = blocking, + } + end) + collectgarbage("restart") + probe.uninstall() + + if case.teardown then + case.teardown(state) + end + if not ok then + error(result, 0) + end + return result +end + +---@param case bench.AsyncCase +---@param opts bench.Options +---@return bench.Result +function M.measure_async(case, opts) + local state = case.setup and case.setup() + + probe.install() + local ok, result = pcall(function() + local wall, main, block = {}, {}, {} + local spawns, blocking = 0, 0 + local warmup = 1 + + for i = 1, warmup + opts.async_samples do + if case.before_each then + case.before_each(state) + end + settle(opts.timeout_ms) + probe.reset() + + local finished_at = nil ---@type number? + local failure = nil ---@type string? + local start = hrtime() + case.run(state, function(err) + finished_at = finished_at or hrtime() + failure = failure or err + end) + local sync_ns = hrtime() - start + + if + not vim.wait(opts.timeout_ms, function() + return finished_at ~= nil + end, 1) + then + error(string.format("the operation did not finish within %d ms", opts.timeout_ms), 0) + end + if failure then + error(failure, 0) + end + settle(opts.timeout_ms) + + if i > warmup then + local main_ns, block_ns = sync_ns, sync_ns + for _, slice in ipairs(probe.slices) do + main_ns = main_ns + slice + block_ns = math.max(block_ns, slice) + end + ---@cast finished_at number + table.insert(wall, (finished_at - start) / 1e6) + table.insert(main, main_ns / 1e6) + table.insert(block, block_ns / 1e6) + spawns = spawns + probe.spawns + blocking = blocking + probe.blocking + end + end + + ---@type bench.Result + return { + kind = "async", + unit = "ms", + samples = wall, + median = median(wall), + min = minimum(wall), + main_ms = median(main), + block_ms = median(block), + spawns = spawns / opts.async_samples, + blocking = blocking / opts.async_samples, + } + end) + probe.uninstall() + + if case.teardown then + case.teardown(state) + end + if not ok then + error(result, 0) + end + return result +end + +---@param case bench.Case +---@param opts bench.Options +---@return bench.Result +function M.measure(case, opts) + if case.async then + ---@cast case bench.AsyncCase + return M.measure_async(case, opts) + end + ---@cast case bench.SyncCase + return M.measure_sync(case, opts) +end + +---@param value number +---@param unit "us" | "ms" +---@return string +function M.format_time(value, unit) + if unit == "ms" then + return string.format("%.2f ms", value) + end + if value >= 1000 then + return string.format("%.2f ms", value / 1000) + end + return string.format("%.2f us", value) +end + +---@param name string +---@param result bench.Result +---@return string +function M.format_result(name, result) + local parts = { + string.format("%-52s", name), + string.format("median %10s", M.format_time(result.median, result.unit)), + string.format("min %10s", M.format_time(result.min, result.unit)), + } + if result.kind == "sync" then + table.insert(parts, string.format("alloc %8.2f KB", result.alloc_kb)) + else + table.insert(parts, string.format("main %8.2f ms", result.main_ms)) + table.insert(parts, string.format("block %7.2f ms", result.block_ms)) + end + table.insert(parts, string.format("spawns %4.1f", result.spawns)) + if result.blocking > 0 then + table.insert(parts, string.format("blocking %3.1f", result.blocking)) + end + return table.concat(parts, " ") +end + +return M diff --git a/benchmarks/run.lua b/benchmarks/run.lua new file mode 100644 index 0000000..0142c10 --- /dev/null +++ b/benchmarks/run.lua @@ -0,0 +1,143 @@ +-- Runs the benchmarks and prints one line for each case. +-- +-- nvim --headless --clean -l benchmarks/run.lua [options] +-- +-- Options: +-- --root DIR Measure the cmp-git code in DIR (default: this repository). The cases always come from this +-- repository, so that two revisions run the same cases. +-- --json FILE Write the results to FILE for benchmarks/compare.lua. +-- --filter PATTERN Run only the cases whose names match the Lua pattern. +-- --samples N Samples for each sync case (default 15). +-- --async-samples N Samples for each async case (default 10). +-- --revision REV The revision to record in the results (default: `git rev-parse --short HEAD` in the root). +-- --allow-errors Exit with 0 when a case fails. Use it for an older revision that lacks an API that a case +-- needs. + +local bench_root = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":p:h:h") +package.path = string.format("%s/?.lua;%s", bench_root, package.path) + +local CASE_MODULES = { "benchmarks.cases.keystroke", "benchmarks.cases.request", "benchmarks.cases.e2e" } + +---@param argv string[] +---@return { root: string, json?: string, filter?: string, revision?: string, samples: integer, async_samples: integer, allow_errors: boolean } +local function parse_args(argv) + local opts = { root = bench_root, samples = 15, async_samples = 10, allow_errors = false } + local i = 1 + while i <= #argv do + local name = argv[i] + local function value() + i = i + 1 + return argv[i] or error("missing value for " .. name, 0) + end + + if name == "--root" then + opts.root = value() + elseif name == "--json" then + opts.json = value() + elseif name == "--filter" then + opts.filter = value() + elseif name == "--revision" then + opts.revision = value() + elseif name == "--samples" then + opts.samples = assert(tonumber(value()), "--samples needs a number") + elseif name == "--async-samples" then + opts.async_samples = assert(tonumber(value()), "--async-samples needs a number") + elseif name == "--allow-errors" then + opts.allow_errors = true + else + error("unknown option " .. name, 0) + end + i = i + 1 + end + opts.root = vim.fs.normalize(vim.fn.fnamemodify(opts.root, ":p")) + return opts +end + +---@param root string +---@return string? +local function revision(root) + local result = vim.system({ "git", "-C", root, "rev-parse", "--short", "HEAD" }, { text = true }):wait() + if result.code ~= 0 then + return nil + end + return vim.trim(result.stdout) +end + +local function main() + -- `nvim -l` gives the script arguments in the global `arg` + -- selene: allow(global_usage) + local opts = parse_args(_G.arg) + + vim.opt.runtimepath:prepend(opts.root) + local core_path = vim.api.nvim_get_runtime_file("lua/cmp_git/core.lua", false)[1] + if not core_path or vim.fs.normalize(core_path) ~= opts.root .. "/lua/cmp_git/core.lua" then + error(string.format("cmp-git does not load from %s (found %s)", opts.root, tostring(core_path)), 0) + end + + local harness = require("benchmarks.harness") + local fixtures = require("benchmarks.fixtures") + fixtures.isolate_environment() + + ---@type bench.Options + local measure_opts = { + samples = opts.samples, + async_samples = opts.async_samples, + min_sample_ns = 5e6, + alloc_ops = 32, + timeout_ms = 10000, + } + + local order = {} + local results = {} ---@type table + local errors = {} ---@type table + + for _, module in ipairs(CASE_MODULES) do + for _, case in ipairs(require(module)) do + if not opts.filter or case.name:find(opts.filter) then + table.insert(order, case.name) + local ok, result = pcall(harness.measure, case, measure_opts) + if ok then + results[case.name] = result + print(harness.format_result(case.name, result)) + else + errors[case.name] = tostring(result) + print(string.format("%-52s ERROR %s", case.name, tostring(result))) + end + end + end + end + + if opts.json then + local version = vim.version() + local uname = vim.uv.os_uname() + local file = assert(io.open(opts.json, "w")) + file:write(vim.json.encode({ + version = 1, + meta = { + date = os.date("!%Y-%m-%dT%H:%M:%SZ"), + nvim = string.format("%d.%d.%d", version.major, version.minor, version.patch), + luajit = package.loaded.jit and package.loaded.jit.version or _VERSION, + os = string.format("%s %s", uname.sysname, uname.machine), + root = opts.root, + revision = opts.revision or revision(opts.root), + }, + order = order, + -- An empty table encodes as a JSON array + results = next(results) and results or vim.empty_dict(), + errors = next(errors) and errors or vim.empty_dict(), + })) + file:close() + end + + fixtures.cleanup() + return (next(errors) == nil or opts.allow_errors) and 0 or 1 +end + +local ok, code = xpcall(main, debug.traceback) +if not ok then + -- selene: allow(incorrect_standard_library_use) + io.stderr:write(tostring(code) .. "\n") + require("benchmarks.fixtures").cleanup() + os.exit(2) +end +os.exit(code) diff --git a/docs/configuration.md b/docs/configuration.md index 1d15f11..0fe5719 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -20,6 +20,15 @@ An entry can use `pattern` in place of `trigger_character`. `pattern` is a Vim r entry applies when the pattern matches the text before the cursor, up to the cursor. The completed text starts at the start of the match. cmp-git also adds the patterns to the `nvim-cmp` keyword pattern. +An entry can also have `keyword_pattern`. Then cmp-git adds `keyword_pattern` to the `nvim-cmp` keyword pattern in place +of `pattern`. `nvim-cmp` filters the items on the text from the start of the keyword match. + +`nvim-cmp` matches the keyword pattern on each keystroke. Use `\zs`, not a lookbehind such as `\@<=`. A lookbehind +makes Vim use its backtracking engine, and its time grows with the square of the line length. + +The trailer value entry of earlier versions used a lookbehind. If an entry has that pattern and no `keyword_pattern`, +cmp-git uses the current trailer value patterns for it. + The default trailer entries come before the `:` entry. As a result, `Co-authored-by:` completes identities, not commits. @@ -196,10 +205,16 @@ A compound filetype, such as `gitcommit.markdown`, matches when one of its parts cmp-git gets the host, the owner, and the repository from the URL of a git remote. It reads the remotes in the order of `remotes`, and uses the first URL that it can parse. `remotes` can also be a single string. -cmp-git runs git in the directory of the current file. If it finds no remote there, it uses the working directory of -Neovim. +cmp-git uses the git repository of the directory of the file in the buffer. If the buffer has no file, or if that +directory is not in a git repository, cmp-git uses the working directory of Neovim. + +cmp-git keeps the repository and its remote URLs for each buffer. It reads them again in these cases: + +- The git config of the repository changes, for example after `git remote add`. +- The repository came from the working directory of Neovim, and that directory changes. +- cmp-git found no repository, and 5 seconds have passed. -If `enableRemoteUrlRewrites` is `true`, cmp-git reads the URL with `git remote get-url`. This command applies the +If `enableRemoteUrlRewrites` is `true`, cmp-git reads the URLs with `git remote -v`. This command applies the `url..insteadOf` rewrites. If it is `false`, cmp-git reads `remote..url` without rewrites. If a remote uses a host alias from your SSH config, add the alias and the real host to `ssh_aliases`: @@ -215,8 +230,9 @@ require("cmp_git").setup({ With this setup, cmp-git uses `github.com` for the remote `git@github-work:owner/repo.git`. The alias must match the full host. -If you set a default repository with `gh repo set-default`, GitHub issues and pull requests come from that repository. -GitHub mentions come from the repository of the remote. +If you set a default repository with `gh repo set-default`, GitHub issues, pull requests, and mentions come from that +repository. cmp-git reads the default repository once for each repository, so restart Neovim after you change it. +cmp-git does not use the default repository in an `octo` buffer. In an `octo` buffer, cmp-git gets the owner and the repository from the buffer name. It gets the host from `github_hostname` in the Octo config. The default host is `github.com`. @@ -260,8 +276,33 @@ require("cmp_git").setup({ }) ``` -The host must be the same as the host in the URL of the remote. cmp-git always adds `github.com` and `gitlab.com`. -With curl, cmp-git uses `https:///api/v3` for GitHub Enterprise and `https:///api/v4` for GitLab. +Each entry must be the same as the host in the URL of the remote. A GitLab entry can also have a path prefix. +cmp-git always adds `github.com` and `gitlab.com`. With curl, cmp-git uses `https:///api/v3` for GitHub +Enterprise and `https:///api/v4` for GitLab. + +A GitLab instance can have a path prefix in its URL, for example `https://example.com/gitlab`. For this instance, add +the host and the path prefix to `gitlab.hosts`: + +```lua +require("cmp_git").setup({ + gitlab = { + hosts = { "example.com/gitlab" }, + }, +}) +``` + +With this setup, cmp-git reads the remote `https://example.com/gitlab/group/repo.git` as the project `group/repo` on +the instance `example.com/gitlab`. With curl, cmp-git uses `https://example.com/gitlab/api/v4`. A path prefix can +have more than one segment. If more than one entry matches the URL, cmp-git uses the entry with the longest path +prefix. + +The path of an SSH remote, such as `git@example.com:group/repo.git`, has no path prefix. cmp-git uses the entry with +the path prefix for this remote when these conditions are true: + +- `github.hosts` and `gitlab.hosts` have only one entry with a path prefix for `example.com`. +- `github.hosts` and `gitlab.hosts` do not have `example.com` without a path prefix. + +Path prefixes are only for GitLab. GitHub Enterprise does not use a path prefix. ## Troubleshooting @@ -276,4 +317,5 @@ To log missing executables, and the exit code and stderr of failed commands, set 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. +`: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. diff --git a/lua/cmp_git/blink.lua b/lua/cmp_git/blink.lua index a60b1b3..923c0a9 100644 --- a/lua/cmp_git/blink.lua +++ b/lua/cmp_git/blink.lua @@ -29,12 +29,7 @@ local function trigger_character_from_context(ctx, line, trigger_characters) return ctx.trigger_character end - if #trigger_characters == 0 then - return nil - end - - local escaped = table.concat(trigger_characters, "") - return string.match(line, "%s*([" .. escaped .. "])%S*$") + return Core.find_trigger_character(line, trigger_characters) end local function cursor_from_context(ctx) @@ -57,6 +52,7 @@ end function Blink:get_completions(ctx, callback) local cancelled = false + local responded = false local cursor = cursor_from_context(ctx) -- blink.cmp gives the full line in `ctx.line`. Pattern trigger actions must match up to the cursor. local line = ctx.cursor_before_line or string.sub(ctx.line or vim.api.nvim_get_current_line(), 1, cursor.character) @@ -66,9 +62,14 @@ function Blink:get_completions(ctx, callback) line = line, bufnr = ctx.bufnr or vim.api.nvim_get_current_buf(), }, function(list) - if cancelled then + -- blink.cmp adds the items of each response of a request to the items of the earlier responses, but each list + -- of cmp-git repeats the earlier items, for example the next page of mentions. Only the first response is + -- needed: blink.cmp keeps its `is_incomplete_forward`, so it asks again on the next keystroke, and that request + -- gets the cached items. + if cancelled or responded then return end + responded = true callback({ items = list.items, @@ -77,9 +78,12 @@ function Blink:get_completions(ctx, callback) }) end) + -- blink.cmp cancels a request when it asks again, which it does on each keystroke after an incomplete response. + -- After the first response, the request only fills the cache that the next request reads, for example with the + -- next pages of mentions, so it continues. return function() cancelled = true - if controller and controller.cancel then + if not responded and controller and controller.cancel then controller:cancel() end end diff --git a/lua/cmp_git/command.lua b/lua/cmp_git/command.lua index 31ea6a1..90cc8c8 100644 --- a/lua/cmp_git/command.lua +++ b/lua/cmp_git/command.lua @@ -12,24 +12,10 @@ local M = {} ---@class cmp_git.CommandSpec ---@field exec string ---@field args string[] ----@field env? table - ----@param spec cmp_git.CommandSpec ----@return table? env -local function command_env(spec) - if spec.env == nil then - return nil - end - - -- NOTE: setting env causes it to not inherit it from the parent environment - local env = vim.tbl_extend("force", spec.env, { - PATH = vim.fn.getenv("PATH"), - }) - for key, value in pairs(env) do - env[key] = tostring(value) - end - return env -end +---@field env? table Added to the environment of Neovim. +---@field stdin? string Written to the standard input. Secrets go here, because other users can read the arguments. +---@field bufnr? integer The buffer whose repository the command runs in, by default the current one. The current +---buffer can change while a request waits for discovery. ---@param spec cmp_git.CommandSpec ---@param callback fun(result: string, success: boolean): nil @@ -40,44 +26,57 @@ function M.build(spec, callback) return nil end - local job_env = command_env(spec) - return { command = spec.exec, handle = nil, cancelled = false, start = function(_, on_complete, suppress_failure_callback) local job = _ - job.handle = vim.system(vim.list_extend({ spec.exec }, spec.args), { - text = true, - env = job_env, - cwd = repository.get_cwd(), - }, function(result) - vim.schedule(function() - if job.cancelled then - if on_complete then - on_complete(false) - end - return - end - local success = result.code == 0 - if not success then - log.fmt_debug("%s returned with exit code %d", spec.exec, result.code) - if result.stderr and result.stderr ~= "" then - log.fmt_debug("%s stderr: %s", spec.exec, result.stderr) - end - else - log.fmt_debug("%s returned with a result", spec.exec) - end - if success or not suppress_failure_callback then - callback(result.stdout or "", success) - end + ---@param result vim.SystemCompleted + local function complete(result) + if job.cancelled then if on_complete then - on_complete(success) + on_complete(false) + end + return + end + + local success = result.code == 0 + if not success then + log.fmt_debug("%s returned with exit code %d", spec.exec, result.code) + if result.stderr and result.stderr ~= "" then + log.fmt_debug("%s stderr: %s", spec.exec, result.stderr) end + else + log.fmt_debug("%s returned with a result", spec.exec) + end + if success or not suppress_failure_callback then + callback(result.stdout or "", success) + end + if on_complete then + on_complete(success) + end + end + + local ok, handle = pcall(vim.system, vim.list_extend({ spec.exec }, spec.args), { + text = true, + env = spec.env, + stdin = spec.stdin, + cwd = repository.get_cwd(spec.bufnr), + }, function(result) + vim.schedule(function() + complete(result) end) end) + if not ok then + -- For example, a cwd that does not exist. The caller gets a failed command, not an error. + vim.schedule(function() + complete({ code = -1, signal = 0, stdout = "", stderr = tostring(handle) }) + end) + return + end + job.handle = handle end, cancel = function(_) _.cancelled = true @@ -90,21 +89,25 @@ end ---@generic TItem ---@param spec cmp_git.CommandSpec ----@param callback fun(list: cmp_git.CompletionList) +---@param callback fun(list: cmp_git.CompletionList, success: boolean) The list is empty when the command fails. ---@param handle_item fun(item: TItem): cmp_git.CompletionItem ----@param handle_parsed? fun(parsed: any): TItem[] +---@param handle_parsed? fun(parsed: any): TItem[]? A result that is not a list is a failure. ---@return cmp_git.SystemJob? function M.build_list(spec, callback, handle_item, handle_parsed) return M.build(spec, function(result, success) if not success then + callback({ items = {}, isIncomplete = false }, false) return end - callback(response.completion_list(result, handle_item, handle_parsed)) + -- An output that is not a list, for example an error object, is a failure + local list, ok = response.completion_list(result, handle_item, handle_parsed) + callback(list, ok) end) end ----Start the second job if the first one fails, handle cases if the first or second job is nil. +---Start the second job if the first one fails, handle cases if the first or second job is nil. Only the last job that +---runs reports a failure to its callback. ---@param first cmp_git.SystemJob? ---@param second cmp_git.SystemJob? ---@return cmp_git.SystemJob? @@ -119,7 +122,7 @@ function M.fallback(first, second) command = first and first.command or (second --[[@as cmp_git.SystemJob]]).command, active = nil, cancelled = false, - start = function(_, on_complete) + start = function(_, on_complete, suppress_failure_callback) local job = _ local function done(success) if on_complete then @@ -134,7 +137,7 @@ function M.fallback(first, second) return end job.active = second - second:start(done) + second:start(done, suppress_failure_callback) return end @@ -152,11 +155,11 @@ function M.fallback(first, second) if second then job.active = second - second:start(done) + second:start(done, suppress_failure_callback) else done(false) end - end, true) + end, second ~= nil or suppress_failure_callback) end, cancel = function(_) _.cancelled = true @@ -180,10 +183,10 @@ end ---@generic TItem ---@param specs cmp_git.CommandSpec[] ----@param callback fun(list: cmp_git.CompletionList) +---@param callback fun(list: cmp_git.CompletionList, success: boolean) The list is empty when all commands fail. ---@param handle_item fun(item: TItem): cmp_git.CompletionItem ----@param handle_parsed? fun(parsed: any): TItem[] ----@return cmp_git.SystemJob? +---@param handle_parsed? fun(parsed: any): TItem[]? A result that is not a list is a failure. +---@return cmp_git.SystemJob? job nil when no command is executable. function M.build_fallback_list(specs, callback, handle_item, handle_parsed) local job for i = #specs, 1, -1 do diff --git a/lua/cmp_git/config.lua b/lua/cmp_git/config.lua index be2a03d..9706077 100644 --- a/lua/cmp_git/config.lua +++ b/lua/cmp_git/config.lua @@ -12,13 +12,24 @@ local sort = require("cmp_git.sort") ---@class cmp_git.Config.PatternTriggerActions ---@field pattern string Vim regex (magic mode) that must match up to the cursor. The match start is the start of the replaced text. +---@field keyword_pattern? string Vim regex that cmp-git adds to the nvim-cmp keyword pattern in place of `pattern`. nvim-cmp filters the items on the text from the start of the match. ---@field actions string[] -- Keep these in sync with the trailer entries in the README. local TRAILER_KEY_PATTERN = [[^[[:alnum:]-]\+]] -- The value cannot contain `:#@!` outside `<...>`. nvim-cmp starts the keyword at the leftmost match, so a looser -- value would hide the other triggers in body lines such as `Note: see #12`. -local TRAILER_VALUE_PATTERN = [[\%(^[[:alnum:]-]\+:\s*\)\@<=\%([^[:space:]<>:#@!][^<>:#@!]*\%(<[^>]*\)\?\)\?]] +local TRAILER_VALUE = [[\%([^[:space:]<>:#@!][^<>:#@!]*\%(<[^>]*\)\?\)\?]] +-- `\zs` and not a lookbehind, because a lookbehind makes Vim use its backtracking engine. Its time grows with the +-- square of the line length, and nvim-cmp matches the keyword pattern on each keystroke. +local TRAILER_VALUE_PATTERN = [[^[[:alnum:]-]\+:\s*\zs]] .. TRAILER_VALUE +-- Vim gives the match that begins first, and `\zs` moves only the start that it reports. The value match begins at the +-- line start, before the `:` trigger match, so the keyword of `Key:` or `Key:value` would start after the colon. The +-- identities and values formats filter on the colon, so the first branch starts the keyword at the colon. +local TRAILER_VALUE_KEYWORD_PATTERN = [[^[[:alnum:]-]\+\zs:\S*\|]] .. TRAILER_VALUE_PATTERN +-- The value pattern of earlier versions. It matches the same text as `TRAILER_VALUE_PATTERN`, slowly. The docs tell +-- users who set their own `trigger_actions` to copy the default entries, so `normalize` replaces it. +local LEGACY_TRAILER_VALUE_PATTERN = [[\%(^[[:alnum:]-]\+:\s*\)\@<=]] .. TRAILER_VALUE local IDENTITY_TRAILER_KEYS = { "Co-authored-by", @@ -148,6 +159,7 @@ local defaults = { }, { pattern = TRAILER_VALUE_PATTERN, + keyword_pattern = TRAILER_VALUE_KEYWORD_PATTERN, actions = { "git_trailer_identities", "git_trailer_values" }, }, { @@ -190,6 +202,20 @@ local function apply_filter_fn(provider_config, capability_names) end end +---@param trigger_action table +---@return table +local function upgrade_trigger_action(trigger_action) + if trigger_action.pattern ~= LEGACY_TRAILER_VALUE_PATTERN or trigger_action.keyword_pattern ~= nil then + return trigger_action + end + + -- A new table, because the entry can be a table of the user config + return vim.tbl_extend("force", trigger_action, { + pattern = TRAILER_VALUE_PATTERN, + keyword_pattern = TRAILER_VALUE_KEYWORD_PATTERN, + }) +end + ---@param overrides? cmp_git.Config Partial user config. ---@return cmp_git.Config function M.normalize(overrides) @@ -204,6 +230,8 @@ function M.normalize(overrides) apply_filter_fn(normalized.github, { "issues", "mentions", "pull_requests" }) apply_filter_fn(normalized.gitlab, { "issues", "mentions", "merge_requests" }) + normalized.trigger_actions = vim.tbl_map(upgrade_trigger_action, normalized.trigger_actions) + return normalized end diff --git a/lua/cmp_git/core.lua b/lua/cmp_git/core.lua index 4f8bd24..ce8840b 100644 --- a/lua/cmp_git/core.lua +++ b/lua/cmp_git/core.lua @@ -1,15 +1,30 @@ -local github = require("cmp_git.sources.github") -local gitlab = require("cmp_git.sources.gitlab") -local git = require("cmp_git.sources.git") +-- The sources and the repository module load on first use, so that a session loads only the code that its requests +-- need. For example, a declined request loads neither, and a GitHub remote does not load the GitLab source. local config = require("cmp_git.config") local log = require("cmp_git.log") -local repository = require("cmp_git.repository") -local trailers = require("cmp_git.provider.capabilities.trailers") +local trailer_lines = require("cmp_git.provider.capabilities.trailer_lines") + +---A completion request from the nvim-cmp or blink.cmp adapter. +---@class cmp_git.CompletionContext +---@field trigger_character? string +---@field cursor? { row: integer, character: integer } A 1-based row and a 0-based byte column. The text edits use bytes, and the nvim-cmp source says so in `get_position_encoding_kind`. +---@field line? string The text before the cursor. +---@field bufnr? integer +---@field params? cmp_git.CmpCompletionParams The nvim-cmp parameters, for legacy `action` entries. +---@field original_callback? fun(list: cmp_git.CompletionList) +---@field edit_start? integer The match start of the pattern entry that runs. local ROUTE_HANDLED = "handled" local ROUTE_UNSUPPORTED_HOST = "unsupported_host" local ROUTE_UNAVAILABLE = "unavailable" +---A final list without items. The completion engines wait for a final list: blink.cmp holds back the items of all +---sources until its timeout, and nvim-cmp waits when cmp-git is its first source. +---@return cmp_git.CompletionList +local function empty_list() + return { items = {}, isIncomplete = false } +end + local function route_status(handled, job) if handled then return ROUTE_HANDLED, job @@ -18,6 +33,22 @@ local function route_status(handled, job) return ROUTE_UNSUPPORTED_HOST, job end +---A built-in action of a hosted provider. It checks the host before it gets the source, so that a request loads only +---the provider of the remote. +---@param provider "github" | "gitlab" +---@param method string The method of the source that completes. +---@return fun(core: table, callback: fun(list: cmp_git.CompletionList), context: table, git_info: cmp_git.GitInfo, trigger_char: string?): string, table? +local function hosted_action(provider, method) + return function(core, callback, context, git_info, trigger_char) + if not vim.tbl_contains(core.config[provider].hosts, git_info.host) then + return ROUTE_UNSUPPORTED_HOST + end + + local source = core.sources[provider] + return route_status(source[method](source, callback, git_info, trigger_char, context.bufnr)) + end +end + local builtin_trigger_actions = { git_commits = function(core, callback, context, _git_info, trigger_char) return route_status(core.sources.git:get_commits(callback, context, trigger_char)) @@ -31,91 +62,110 @@ local builtin_trigger_actions = { git_trailer_values = function(core, callback, context, _git_info, trigger_char) return route_status(core.sources.git:get_trailer_values(callback, context, trigger_char)) end, - github_issues = function(core, callback, _context, git_info, trigger_char) - return route_status(core.sources.github:complete_issues(callback, git_info, trigger_char)) - end, - github_mentions = function(core, callback, _context, git_info, trigger_char) - return route_status(core.sources.github:complete_mentions(callback, git_info, trigger_char)) - end, - github_change_requests = function(core, callback, _context, git_info, trigger_char) - return route_status(core.sources.github:complete_change_requests(callback, git_info, trigger_char)) - end, - github_issues_and_change_requests = function(core, callback, _context, git_info, trigger_char) - return route_status(core.sources.github:complete_issues_and_change_requests(callback, git_info, trigger_char)) - end, - gitlab_issues = function(core, callback, _context, git_info, trigger_char) - return route_status(core.sources.gitlab:complete_issues(callback, git_info, trigger_char)) - end, - gitlab_mentions = function(core, callback, _context, git_info, trigger_char) - return route_status(core.sources.gitlab:complete_mentions(callback, git_info, trigger_char)) - end, - gitlab_change_requests = function(core, callback, _context, git_info, trigger_char) - return route_status(core.sources.gitlab:complete_change_requests(callback, git_info, trigger_char)) - end, - gitlab_mrs = function(core, callback, _context, git_info, trigger_char) - return route_status(core.sources.gitlab:complete_change_requests(callback, git_info, trigger_char)) - end, - github_issues_and_prs = function(core, callback, _context, git_info, trigger_char) - return route_status(core.sources.github:complete_issues_and_change_requests(callback, git_info, trigger_char)) - end, + github_issues = hosted_action("github", "complete_issues"), + github_mentions = hosted_action("github", "complete_mentions"), + github_change_requests = hosted_action("github", "complete_change_requests"), + github_issues_and_change_requests = hosted_action("github", "complete_issues_and_change_requests"), + gitlab_issues = hosted_action("gitlab", "complete_issues"), + gitlab_mentions = hosted_action("gitlab", "complete_mentions"), + gitlab_change_requests = hosted_action("gitlab", "complete_change_requests"), + gitlab_mrs = hosted_action("gitlab", "complete_change_requests"), + github_issues_and_prs = hosted_action("github", "complete_issues_and_change_requests"), +} + +-- Built-in actions that read only the local repository. They need no remote, and they always handle the request. +local LOCAL_ACTIONS = { + git_commits = true, + git_trailer_keys = true, + git_trailer_identities = true, + git_trailer_values = true, } -- Synchronous checks that run before repository discovery, so that a declined request starts no git process. local builtin_trigger_availability = { git_trailer_keys = function(core, context) - return trailers.available(core.config.git.trailers, context, "keys") + return trailer_lines.available(core.config.git.trailers, context, "keys") end, git_trailer_identities = function(core, context) - return trailers.available(core.config.git.trailers, context, "identities") + return trailer_lines.available(core.config.git.trailers, context, "identities") end, git_trailer_values = function(core, context) - return trailers.available(core.config.git.trailers, context, "values") + return trailer_lines.available(core.config.git.trailers, context, "values") and core.sources.git:may_have_trailer_values(context) end, } ---@param pattern string +---@param field "pattern" | "keyword_pattern" The config field, for the warning. ---@return any? regex -local function compile_pattern(pattern) +local function compile_pattern(pattern, field) local ok, regex = pcall(vim.regex, [[\%(]] .. pattern .. [[\)$]]) if not ok then - log.warn(string.format("Invalid trigger action pattern %s: %s", vim.inspect(pattern), regex)) + log.warn(string.format("Invalid trigger action %s %s: %s", field, vim.inspect(pattern), regex)) return nil end return regex end -local function normalize_trigger_action(configured_action) +---@class cmp_git.TriggerAction +---@field debug_name string +---@field trigger_character? string +---@field regex? any The compiled `pattern`. Entries with the same pattern share it. +---@field keyword_pattern? string +---@field available? fun(core: table, context: table): boolean +---@field needs_remote boolean The action reads `git_info`. +---@field always_handles boolean The action never declines the request, so the actions after it never run. +---@field action fun(core: table, callback: fun(list: cmp_git.CompletionList), context: table, git_info: cmp_git.GitInfo, trigger_char: string?): string, table? + +---@param configured_action table +---@param regexes table The compiled patterns, by pattern. +---@return cmp_git.TriggerAction[] +local function normalize_trigger_action(configured_action, regexes) if configured_action.action then if configured_action.pattern then log.warn("Trigger action patterns need named `actions`. The pattern of a legacy `action` is ignored.") end return { - debug_name = configured_action.debug_name, - trigger_character = configured_action.trigger_character, - action = function(core, _callback, context, git_info, trigger_char) - return route_status( - configured_action.action( - core.sources, - trigger_char, - context.original_callback, - context.params, - git_info + { + debug_name = configured_action.debug_name, + trigger_character = configured_action.trigger_character, + needs_remote = true, + always_handles = false, + action = function(core, _callback, context, git_info, trigger_char) + return route_status( + configured_action.action( + core.sources, + trigger_char, + context.original_callback, + context.params, + git_info + ) ) - ) - end, + end, + }, } end local regex = nil + local keyword_pattern = nil local trigger_character = configured_action.trigger_character if configured_action.pattern then - regex = compile_pattern(configured_action.pattern) + regex = regexes[configured_action.pattern] or compile_pattern(configured_action.pattern, "pattern") if not regex then return {} end + regexes[configured_action.pattern] = regex trigger_character = nil + + keyword_pattern = configured_action.pattern + -- An invalid keyword pattern would make nvim-cmp fail on each keystroke + if + configured_action.keyword_pattern + and compile_pattern(configured_action.keyword_pattern, "keyword_pattern") + then + keyword_pattern = configured_action.keyword_pattern + end end local normalized = {} @@ -124,9 +174,11 @@ local function normalize_trigger_action(configured_action) table.insert(normalized, { debug_name = action_name, trigger_character = trigger_character, - pattern = configured_action.pattern, + keyword_pattern = keyword_pattern, regex = regex, available = builtin_trigger_availability[action_name], + needs_remote = action ~= nil and not LOCAL_ACTIONS[action_name], + always_handles = LOCAL_ACTIONS[action_name] == true, action = function(core, callback, context, git_info, trigger_char) if not action then return ROUTE_UNAVAILABLE @@ -145,6 +197,31 @@ end ---@field github cmp_git.Source.GitHub ---@field gitlab cmp_git.Source.Gitlab +local SOURCE_MODULES = { + git = "cmp_git.sources.git", + github = "cmp_git.sources.github", + gitlab = "cmp_git.sources.gitlab", +} + +---The sources, each made on first use. A legacy callback action gets this table, so a source must also load when an +---action reads it. +---@param core_config cmp_git.Config +---@return cmp_git.Sources +local function lazy_sources(core_config) + return setmetatable({}, { + __index = function(sources, name) + local module = SOURCE_MODULES[name] + if module == nil then + return nil + end + + local source = require(module).new(core_config[name]) + rawset(sources, name, source) + return source + end, + }) +end + local Core = {} function Core.new(overrides) @@ -153,8 +230,9 @@ function Core.new(overrides) }) self.config = config.normalize(overrides) + self.hosts = vim.list_extend(vim.list_extend({}, self.config.github.hosts), self.config.gitlab.hosts) self.filetypes = {} - self.sources = {} + self.sources = lazy_sources(self.config) self.trigger_actions = {} self.trigger_characters = {} @@ -162,22 +240,14 @@ function Core.new(overrides) self.filetypes[item] = true end - self.sources.git = git.new(self.config.git) - self.sources.gitlab = gitlab.new(self.config.gitlab) - self.sources.github = github.new(self.config.github) - local patterns = {} + local regexes = {} for _, configured_action in ipairs(self.config.trigger_actions) do - local normalized = normalize_trigger_action(configured_action) - if normalized.action then - normalized = { normalized } - end - - for _, trigger_action in ipairs(normalized) do + for _, trigger_action in ipairs(normalize_trigger_action(configured_action, regexes)) do table.insert(self.trigger_actions, trigger_action) if trigger_action.regex then - if not vim.tbl_contains(patterns, trigger_action.pattern) then - table.insert(patterns, trigger_action.pattern) + if not vim.tbl_contains(patterns, trigger_action.keyword_pattern) then + table.insert(patterns, trigger_action.keyword_pattern) end elseif trigger_action.trigger_character @@ -189,10 +259,20 @@ function Core.new(overrides) end self.trigger_characters_str = table.concat(self.trigger_characters, "") - self.keyword_pattern = string.format("[%s]\\S*", self.trigger_characters_str) + local trigger_pattern = nil + if #self.trigger_characters > 0 then + -- In a Vim collection, `]`, `^`, `-`, and `\` need a backslash. A backslash before another character would + -- add a backslash to the collection. + local collection = string.gsub(self.trigger_characters_str, "[%]%^%-\\]", "\\%0") + trigger_pattern = "[" .. collection .. [[]\S*]] + end + + -- nil when there is nothing to match, because an empty collection is an invalid regex. nvim-cmp then uses its + -- default keyword pattern. + self.keyword_pattern = trigger_pattern if #patterns > 0 then - if #self.trigger_characters > 0 then - table.insert(patterns, self.keyword_pattern) + if trigger_pattern then + table.insert(patterns, trigger_pattern) end self.keyword_pattern = [[\%(]] .. table.concat(patterns, [[\|]]) .. [[\)]] end @@ -200,6 +280,20 @@ function Core.new(overrides) return self 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. +---@param trigger_characters string[] +---@return string? +function Core.find_trigger_character(line, trigger_characters) + if #trigger_characters == 0 then + return nil + end + + -- `vim.pesc` escapes each character that is special in a Lua pattern set + return string.match(line, "([" .. vim.pesc(table.concat(trigger_characters)) .. "])%S*$") +end + local function find_trigger_start(line, trigger_character, cursor_character) if not line or not trigger_character then return cursor_character @@ -220,72 +314,102 @@ 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 +---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[] +---@param context? cmp_git.CompletionContext +---@return cmp_git.CompletionItem[] function Core.apply_text_edits(items, context) - if not context or not context.cursor then - return - end - - local row = context.cursor.row or context.cursor.line or 1 - local character = context.cursor.character or context.cursor.col or 0 - local start_character = context.edit_start or find_trigger_start(context.line, context.trigger_character, character) - - for _, item in ipairs(items or {}) do - local new_text = item.insertText or item.label - item.textEdit = { - range = { - start = { - line = row - 1, - character = start_character, - }, - ["end"] = { - line = row - 1, - character = character, - }, + local range = nil + if context and context.cursor then + local row = context.cursor.row + local character = context.cursor.character + local start_character = context.edit_start + or find_trigger_start(context.line, context.trigger_character, character) + -- One range for all the items, which halves the cost. blink.cmp copies a range before it changes it, and + -- nvim-cmp shares equal ranges between its entries anyway. + range = { + start = { + line = row - 1, + character = start_character, + }, + ["end"] = { + line = row - 1, + character = character, }, - newText = new_text, } end + + local edited = {} + for index, item in ipairs(items or {}) do + local copy = {} + for key, value in pairs(item) do + copy[key] = value + end + if range then + copy.textEdit = { range = range, newText = item.insertText or item.label } + end + edited[index] = copy + end + + return edited end function Core:_wrap_callback(context, callback) return function(list) - Core.apply_text_edits(list.items, context) - callback(list) + callback({ items = Core.apply_text_edits(list.items, context), isIncomplete = list.isIncomplete }) end end +---@class cmp_git.Route +---@field trigger_action cmp_git.TriggerAction +---@field edit_start? integer The match start of a pattern entry. + ---Find the trigger actions that apply to the context, in config order. This must stay synchronous and start no ---process, because it runs for every completion request. ----@return { trigger_action: table, edit_start: integer? }[] +---@return cmp_git.Route[] function Core:_route(context) local routes = {} + -- The match start of each regex, or false. The actions of one entry share its regex. + local matches = nil for _, trigger_action in ipairs(self.trigger_actions) do - local matched = false - local edit_start = nil - if trigger_action.regex then - edit_start = context.line and trigger_action.regex:match_str(context.line) - matched = edit_start ~= nil - else - matched = trigger_action.trigger_character ~= nil + local regex = trigger_action.regex + local candidate = regex ~= nil + or ( + trigger_action.trigger_character ~= nil and trigger_action.trigger_character == context.trigger_character - end + ) + + -- `available` runs before the regex, because it is cheap, and a regex can take long on a long line + if candidate and (not trigger_action.available or trigger_action.available(self, context)) then + local edit_start = nil + if regex then + matches = matches or {} + if matches[regex] == nil then + matches[regex] = context.line and regex:match_str(context.line) or false + end + edit_start = matches[regex] or nil + end - if matched and (not trigger_action.available or trigger_action.available(self, context)) then - table.insert(routes, { trigger_action = trigger_action, edit_start = edit_start }) + if not regex or edit_start then + table.insert(routes, { trigger_action = trigger_action, edit_start = edit_start }) + end end end return routes end -function Core:_run_trigger_actions(trigger_character, callback, context, git_info) +---@param routes? cmp_git.Route[] The routes of the context, when the caller has them. +function Core:_run_trigger_actions(trigger_character, callback, context, git_info, routes) context = context or {} context.trigger_character = trigger_character context.original_callback = callback local wrapped_callback = self:_wrap_callback(context, callback) - for _, route in ipairs(self:_route(context)) do + for _, route in ipairs(routes or self:_route(context)) do -- Set before the action runs, because a cache hit calls the callback synchronously context.edit_start = route.edit_start local result, job = route.trigger_action.action(self, wrapped_callback, context, git_info, trigger_character) @@ -293,9 +417,36 @@ function Core:_run_trigger_actions(trigger_character, callback, context, git_inf return job end end + + -- No action handled the request, for example `#` with a remote on another host + callback(empty_list()) +end + +---Whether an action that can run reads `git_info`. +---@param routes cmp_git.Route[] +---@return boolean +local function routes_need_remote(routes) + for _, route in ipairs(routes) do + if route.trigger_action.needs_remote then + return true + end + if route.trigger_action.always_handles then + return false + end + end + return false +end + +---@param bufnr? integer +---@return boolean +local function is_octo_buffer(bufnr) + bufnr = bufnr or vim.api.nvim_get_current_buf() + return vim.api.nvim_buf_is_valid(bufnr) and vim.bo[bufnr].filetype == "octo" end -function Core:_complete(context, callback) +---@param routes? cmp_git.Route[] The routes of the context, when the caller has them. +function Core:_complete(context, callback, routes) + routes = routes or self:_route(context) local state = { cancelled = false, active = nil } local controller = { command = "cmp_git_complete", @@ -307,20 +458,32 @@ function Core:_complete(context, callback) end, } - repository.discover(self.config.remotes, { + local function run(git_info) + if state.cancelled then + return + end + state.active = self:_run_trigger_actions(context.trigger_character, callback, context, git_info, routes) + end + + -- Discovery starts a git process for each remote that it checks + if not routes_need_remote(routes) then + run({}) + return controller + end + + require("cmp_git.repository").discover(self.config.remotes, { enableRemoteUrlRewrites = self.config.enableRemoteUrlRewrites, ssh_aliases = self.config.ssh_aliases, - on_complete = function(git_info) - if state.cancelled then - return - end - state.active = self:_run_trigger_actions(context.trigger_character, callback, context, git_info) - end, + hosts = self.hosts, + on_complete = run, + bufnr = context.bufnr, }) return controller end +---@param context cmp_git.CompletionContext +---@param callback fun(list: cmp_git.CompletionList) function Core:complete(context, callback) local state = { cancelled = false, active = nil } local controller = { @@ -333,20 +496,31 @@ function Core:complete(context, callback) end, } - if #self:_route(context) == 0 then - callback({ items = {}, isIncomplete = false }) + -- Routing once for each request matches each regex once + local routes = self:_route(context) + if #routes == 0 then + callback(empty_list()) + return controller + end + + -- Discovery reads the repository of an octo buffer from the buffer name, so the actions that need a remote work + -- outside a local repository + if is_octo_buffer(context.bufnr) and routes_need_remote(routes) then + state.active = self:_complete(context, callback, routes) return controller end - repository.is_git_repo(function(is_git_repo) + -- The buffer of the request, because the current buffer can change while git runs + require("cmp_git.repository").is_git_repo(function(is_git_repo) if state.cancelled then return end if not is_git_repo then + callback(empty_list()) return end - state.active = self:_complete(context, callback) - end) + state.active = self:_complete(context, callback, routes) + end, context.bufnr) return controller end diff --git a/lua/cmp_git/provider/adapters/github.lua b/lua/cmp_git/provider/adapters/github.lua index b2b79cf..a8511b5 100644 --- a/lua/cmp_git/provider/adapters/github.lua +++ b/lua/cmp_git/provider/adapters/github.lua @@ -8,6 +8,7 @@ local M = {} ---@field title string ---@field body string ---@field updatedAt string +---@field updated_at? string The name of `updatedAt` in the REST API. ---@field state string ---A pull request from `gh pr list --json` or the REST API. @@ -34,25 +35,39 @@ local function env() } end -local function curl_args(curl_url) - local args = { - "-s", +---@param curl_url string +---@param opts? { error_body?: boolean } `error_body` gives the body of an HTTP error with exit code 0. +---@return cmp_git.CommandSpec +local function curl_command(curl_url, opts) + local args = { "-s", "-S" } + if not (opts and opts.error_body) then + -- Without it, curl exits with 0 on an HTTP error, and the error object looks like an empty list + table.insert(args, "--fail") + end + vim.list_extend(args, { "-L", "-H", "Accept: application/vnd.github.v3+json", - curl_url, - } + }) + local stdin = nil - if vim.fn.exists("$GITHUB_API_TOKEN") == 1 then - local token = vim.fn.getenv("GITHUB_API_TOKEN") - table.insert(args, "-H") - table.insert(args, string.format("Authorization: token %s", token)) + local token = vim.env.GITHUB_API_TOKEN + if token and token ~= "" then + -- `-H @-` reads the header from stdin, so that the token is not in the process list + vim.list_extend(args, { "-H", "@-" }) + stdin = string.format("Authorization: token %s\n", token) end - return args + table.insert(args, curl_url) + return { exec = "curl", args = args, stdin = stdin } end -local function normalize_body(item) +---@param item cmp_git.GitHub.Issue +local function normalize_issue(item) + -- JSON null is `vim.NIL`, which the formats would show as the text "vim.NIL" + if item.title == nil or item.title == vim.NIL then + item.title = "" + end if item.body ~= vim.NIL then item.body = string.gsub(item.body or "", "\r", "") else @@ -100,19 +115,19 @@ function M.issues_request(git_info, trigger_char, config) return { commands = { { exec = "gh", args = gh_args, env = env() }, - { exec = "curl", args = curl_args(github_url(git_info.host, curl_path)) }, + curl_command(github_url(git_info.host, curl_path)), }, handle_item = function(issue) - normalize_body(issue) + normalize_issue(issue) return format.item(config, trigger_char, issue) end, -- The REST API also returns pull requests, with a `pull_request` field. `gh issue list` does not. handle_parsed = function(parsed) - local result = {} - if type(parsed) ~= "table" then - return result + if not vim.islist(parsed) then + return nil end + local result = {} for _, issue in ipairs(parsed) do if issue.pull_request == nil or issue.pull_request == vim.NIL then table.insert(result, issue) @@ -142,25 +157,22 @@ function M.change_requests_request(git_info, trigger_char, config) }, env = env(), }, - { - exec = "curl", - args = curl_args( - github_url( - git_info.host, - string.format( - "repos/%s/%s/pulls?state=%s&per_page=%d&page=%d", - git_info.owner, - git_info.repo, - config.state, - config.limit, - 1 - ) + curl_command( + github_url( + git_info.host, + string.format( + "repos/%s/%s/pulls?state=%s&per_page=%d&page=%d", + git_info.owner, + git_info.repo, + config.state, + config.limit, + 1 ) - ), - }, + ) + ), }, handle_item = function(pr) - normalize_body(pr) + normalize_issue(pr) return format.item(config, trigger_char, pr) end, } @@ -179,20 +191,19 @@ function M.mentions_preflight_request(git_info) }, env = env(), }, - { - exec = "curl", - args = curl_args( - github_url( - git_info.host, - string.format("repos/%s/%s/collaborators/testing/permission", git_info.owner, git_info.repo) - ) + -- `handle_result` reads the status of an HTTP error from the body + curl_command( + github_url( + git_info.host, + string.format("repos/%s/%s/collaborators/testing/permission", git_info.owner, git_info.repo) ), - }, + { error_body = true } + ), }, handle_result = function(result) local ok, parsed = pcall(vim.json.decode, result) local member_type = "contributors" - if ok then + if ok and type(parsed) == "table" then member_type = (parsed.status ~= "403" and parsed.status ~= "401") and "collaborators" or "contributors" end return { member_type = member_type } @@ -221,28 +232,25 @@ function M.mentions_request(git_info, trigger_char, config, opts) }, env = env(), }, - { - exec = "curl", - args = curl_args( - github_url( - git_info.host, - string.format( - "repos/%s/%s/%s?per_page=%d&page=%d", - git_info.owner, - git_info.repo, - member_type, - opts.page_size, - opts.page - ) + curl_command( + github_url( + git_info.host, + string.format( + "repos/%s/%s/%s?per_page=%d&page=%d", + git_info.owner, + git_info.repo, + member_type, + opts.page_size, + opts.page ) - ), - }, + ) + ), }, handle_item = function(mention) return format.item(config, trigger_char, mention) end, handle_parsed = function(parsed) - if parsed["mentionableUsers"] then + if type(parsed) == "table" and parsed["mentionableUsers"] then return parsed["mentionableUsers"] end return parsed diff --git a/lua/cmp_git/provider/adapters/gitlab.lua b/lua/cmp_git/provider/adapters/gitlab.lua index 6dc192b..fb12dfe 100644 --- a/lua/cmp_git/provider/adapters/gitlab.lua +++ b/lua/cmp_git/provider/adapters/gitlab.lua @@ -31,19 +31,37 @@ local function env() } end -local function curl_args(curl_url) - local args = { - "-s", - curl_url, - } +---Replace the missing and null text fields that the formats read. JSON null is `vim.NIL`, which a format would show as +---the text "vim.NIL". +---@generic T: table +---@param item T +---@param fields string[] +---@return T item +local function empty_nulls(item, fields) + for _, field in ipairs(fields) do + if item[field] == nil or item[field] == vim.NIL then + item[field] = "" + end + end + return item +end - if vim.fn.exists("$GITLAB_TOKEN") == 1 then - local token = vim.fn.getenv("GITLAB_TOKEN") - table.insert(args, "-H") - table.insert(args, string.format("Authorization: Bearer %s", token)) +---@param curl_url string +---@return cmp_git.CommandSpec +local function curl_command(curl_url) + -- Without --fail, curl exits with 0 on an HTTP error, and the error object looks like an empty list + local args = { "-s", "-S", "--fail" } + local stdin = nil + + local token = vim.env.GITLAB_TOKEN + if token and token ~= "" then + -- `-H @-` reads the header from stdin, so that the token is not in the process list + vim.list_extend(args, { "-H", "@-" }) + stdin = string.format("Authorization: Bearer %s\n", token) end - return args + table.insert(args, curl_url) + return { exec = "curl", args = args, stdin = stdin } end function M.issues_request(git_info, trigger_char, config) @@ -58,24 +76,18 @@ function M.issues_request(git_info, trigger_char, config) }, env = env(), }, - { - exec = "curl", - args = curl_args( - string.format( - "https://%s/api/v4/projects/%s/issues?per_page=%d&state=%s", - git_info.host, - id, - config.limit, - config.state - ) - ), - }, + curl_command( + string.format( + "https://%s/api/v4/projects/%s/issues?per_page=%d&state=%s", + git_info.host, + id, + config.limit, + config.state + ) + ), }, handle_item = function(issue) - if issue.description == vim.NIL then - issue.description = "" - end - return format.item(config, trigger_char, issue) + return format.item(config, trigger_char, empty_nulls(issue, { "title", "description" })) end, } end @@ -92,21 +104,18 @@ function M.change_requests_request(git_info, trigger_char, config) }, env = env(), }, - { - exec = "curl", - args = curl_args( - string.format( - "https://%s/api/v4/projects/%s/merge_requests?per_page=%d&state=%s", - git_info.host, - id, - config.limit, - config.state - ) - ), - }, + curl_command( + string.format( + "https://%s/api/v4/projects/%s/merge_requests?per_page=%d&state=%s", + git_info.host, + id, + config.limit, + config.state + ) + ), }, handle_item = function(mr) - return format.item(config, trigger_char, mr) + return format.item(config, trigger_char, empty_nulls(mr, { "title", "description" })) end, } end @@ -123,15 +132,12 @@ function M.mentions_request(git_info, trigger_char, config) }, env = env(), }, - { - exec = "curl", - args = curl_args( - string.format("https://%s/api/v4/projects/%s/users?per_page=%d", git_info.host, id, config.limit) - ), - }, + curl_command( + string.format("https://%s/api/v4/projects/%s/users?per_page=%d", git_info.host, id, config.limit) + ), }, handle_item = function(mention) - return format.item(config, trigger_char, mention) + return format.item(config, trigger_char, empty_nulls(mention, { "name" })) end, } end diff --git a/lua/cmp_git/provider/capabilities/change_requests.lua b/lua/cmp_git/provider/capabilities/change_requests.lua index 7861721..b434c8f 100644 --- a/lua/cmp_git/provider/capabilities/change_requests.lua +++ b/lua/cmp_git/provider/capabilities/change_requests.lua @@ -1,4 +1,4 @@ -local command = require("cmp_git.command") +local issues = require("cmp_git.provider.capabilities.issues") local M = {} @@ -12,12 +12,7 @@ function M.job(args) return nil end - local request = args.adapter.change_requests_request(args.git_info, args.trigger_char, args.config) - local runner = args.runner or command - return runner.build_fallback_list(request.commands, function(list) - args.cache[bufnr] = list.items - args.callback(list) - end, request.handle_item, request.handle_parsed) + return issues.list_job(args, args.adapter.change_requests_request(args.git_info, args.trigger_char, args.config)) end ---@param args cmp_git.Provider.CapabilityArgs diff --git a/lua/cmp_git/provider/capabilities/commits.lua b/lua/cmp_git/provider/capabilities/commits.lua index f774f76..9f85640 100644 --- a/lua/cmp_git/provider/capabilities/commits.lua +++ b/lua/cmp_git/provider/capabilities/commits.lua @@ -4,8 +4,12 @@ local utils = require("cmp_git.utils") local M = {} -local trim = utils.trim -local split_nul = utils.split_nul +local find = string.find +local sub = string.sub +local trim_range = utils.trim_range + +-- The NUL-terminated fields of each commit in the `M.request` format +local FIELDS = 6 ---@param config cmp_git.Config.GitCommits ---@return cmp_git.CommandSpec @@ -22,32 +26,63 @@ function M.request(config) } end +---@param s string +---@param first integer +---@param last integer +---@return string +local function trimmed(s, first, last) + return sub(s, trim_range(s, first, last)) +end + ---@param result string ---@param config cmp_git.Config.GitCommits ---@return cmp_git.Commit[] function M.parse(result, config) - local fields = split_nul(result) local commits = {} + local now = os.time() + local sha_length = config.sha_length + -- The position of the NUL after each field of the current commit. The fields are read in place, so that each + -- field is copied once, after the trim. + local stops = {} + local start = 1 - for i = 1, #fields, 6 do - if fields[i + 5] then - local timestamp = tonumber(trim(fields[i + 5])) or 0 - ---@class cmp_git.Commit - local commit = { - sha = trim(fields[i]):sub(1, config.sha_length), - title = trim(fields[i + 1]), - description = trim(fields[i + 2]), - author_name = fields[i + 3] or "", - author_mail = fields[i + 4] or "", - commit_timestamp = timestamp, - diff = os.difftime(os.time(), timestamp), - } - - table.insert(commits, commit) + while true do + local from = start + for field = 1, FIELDS do + local stop = find(result, "\0", from, true) + -- A trailing commit without all its fields is dropped + if not stop then + return commits + end + stops[field] = stop + from = stop + 1 end - end - return commits + -- `--pretty=format:` puts a newline between commits, so the sha starts with whitespace + local sha_first, sha_last = trim_range(result, start, stops[1] - 1) + local sha + if sha_length >= 0 then + sha = sub(result, sha_first, math.min(sha_last, sha_first + sha_length - 1)) + else + sha = sub(result, sha_first, sha_last):sub(1, sha_length) + end + + -- `tonumber` ignores surrounding whitespace + local timestamp = tonumber(sub(result, stops[5] + 1, stops[6] - 1)) or 0 + ---@class cmp_git.Commit + local commit = { + sha = sha, + title = trimmed(result, stops[1] + 1, stops[2] - 1), + description = trimmed(result, stops[2] + 1, stops[3] - 1), + author_name = sub(result, stops[3] + 1, stops[4] - 1), + author_mail = sub(result, stops[4] + 1, stops[5] - 1), + commit_timestamp = timestamp, + diff = os.difftime(now, timestamp), + } + commits[#commits + 1] = commit + + start = stops[FIELDS] + 1 + end end ---@param commits cmp_git.Commit[] @@ -80,9 +115,18 @@ function M.job(args) return nil end + -- A failure, such as a repository without commits or no git, must still answer. Else blink.cmp holds back the + -- items of all sources until its timeout. It is not cached, so that the next request tries again. + local function fail() + args.callback({ items = {}, isIncomplete = false }) + end + + local spec = M.request(args.config) + spec.bufnr = bufnr local runner = args.runner or command - return runner.build(M.request(args.config), function(result, success) + local job = runner.build(spec, function(result, success) if not success then + fail() return end @@ -90,6 +134,11 @@ function M.job(args) args.cache[bufnr] = parsed args.callback({ items = M.items(parsed, args.config, args.trigger_char), isIncomplete = false }) end) + + if not job then + fail() + end + return job end ---@param args cmp_git.Provider.CommitCapabilityArgs diff --git a/lua/cmp_git/provider/capabilities/issues.lua b/lua/cmp_git/provider/capabilities/issues.lua index 5dfd869..a015e9f 100644 --- a/lua/cmp_git/provider/capabilities/issues.lua +++ b/lua/cmp_git/provider/capabilities/issues.lua @@ -5,7 +5,7 @@ local M = {} ---@class cmp_git.Provider.ListRequest ---@field commands cmp_git.CommandSpec[] ---@field handle_item fun(item: any): cmp_git.CompletionItem ----@field handle_parsed? fun(parsed: any): any[] +---@field handle_parsed? fun(parsed: any): any[]? A result that is not a list is a failure. ---@class cmp_git.Provider.CapabilityArgs ---@field adapter table @@ -17,6 +17,30 @@ local M = {} ---@field runner? table ---@field bufnr? integer +---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. +---@param args cmp_git.Provider.CapabilityArgs +---@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 + args.callback({ items = {}, isIncomplete = false }) + return + end + + args.cache[bufnr] = list.items + args.callback(list) + end, request.handle_item, request.handle_parsed) + + if not job then + args.callback({ items = {}, isIncomplete = false }) + end + return job +end + ---@param args cmp_git.Provider.CapabilityArgs ---@return cmp_git.SystemJob? function M.job(args) @@ -27,12 +51,7 @@ function M.job(args) return nil end - local request = args.adapter.issues_request(args.git_info, args.trigger_char, args.config) - local runner = args.runner or command - return runner.build_fallback_list(request.commands, function(list) - args.cache[bufnr] = list.items - args.callback(list) - end, request.handle_item, request.handle_parsed) + return M.list_job(args, args.adapter.issues_request(args.git_info, args.trigger_char, args.config)) end ---@param args cmp_git.Provider.CapabilityArgs diff --git a/lua/cmp_git/provider/capabilities/mentions.lua b/lua/cmp_git/provider/capabilities/mentions.lua index f538d1c..12519d7 100644 --- a/lua/cmp_git/provider/capabilities/mentions.lua +++ b/lua/cmp_git/provider/capabilities/mentions.lua @@ -2,108 +2,198 @@ local command = require("cmp_git.command") 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. +---@class cmp_git.Provider.MentionFetch +---@field subscribers { callback: fun(list: cmp_git.CompletionList) }[] +---@field active? cmp_git.SystemJob +---@field cancelled boolean + ---@class cmp_git.Provider.MentionCapabilityArgs: cmp_git.Provider.CapabilityArgs ---@field cache table ----@param args cmp_git.Provider.MentionCapabilityArgs -function M.complete(args) - local bufnr = args.bufnr or vim.api.nvim_get_current_buf() - local runner = args.runner or command - local state = { cancelled = false, active = nil } - local controller = { +---@param subscribers { callback: fun(list: cmp_git.CompletionList) }[] +---@param list cmp_git.CompletionList +local function notify(subscribers, list) + -- A copy, because a callback can cancel its request + for _, subscriber in ipairs(vim.list_extend({}, subscribers)) do + subscriber.callback(list) + 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 } + table.insert(fetch.subscribers, subscriber) + + return { command = "mentions", cancel = function() - state.cancelled = true - if state.active and state.active.cancel then - state.active:cancel() + for i, other in ipairs(fetch.subscribers) do + if other == subscriber then + table.remove(fetch.subscribers, i) + break + end + end + if #fetch.subscribers > 0 or fetch.cancelled or not entry.in_progress then + return + end + + -- Otherwise the entry stays in progress, and later requests wait for pages that never come + fetch.cancelled = true + if cache[bufnr] == entry then + cache[bufnr] = nil + end + if fetch.active and fetch.active.cancel then + fetch.active:cancel() end end, } +end - if args.cache[bufnr] then - local mentions_cache = args.cache[bufnr] - args.callback({ items = mentions_cache.items, isIncomplete = mentions_cache.in_progress }) - return nil - end - - args.cache[bufnr] = { items = {}, in_progress = true } - - local function fetch_mentions(page, context) - if state.cancelled then - return +---@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) + 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 + local page_size = math.min(limit, MAX_PAGE_SIZE) + + ---@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 }) + end - local mentions_cache = args.cache[bufnr] - local remaining = args.config.limit - #mentions_cache.items - if remaining <= 0 then - mentions_cache.in_progress = false - args.callback({ items = mentions_cache.items, isIncomplete = false }) - return - end + if limit < 1 then + finish(true) + return + end - local page_size = math.min(remaining, 100) + local function fetch_page(page, context) local request = args.adapter.mentions_request(args.git_info, args.trigger_char, args.config, { context = context, page = page, page_size = page_size, }) - local job = runner.build_fallback_list(request.commands, function(list) - if state.cancelled then + local job = runner.build_fallback_list(request.commands, function(list, success) + if fetch.cancelled then + return + end + if not success then + finish(false) return end - vim.list_extend(mentions_cache.items, list.items) - + local items = vim.list_extend(vim.list_extend({}, entry.items), list.items) + local has_more if args.adapter.mentions_has_more then - mentions_cache.in_progress = - args.adapter.mentions_has_more(list, mentions_cache.items, args.config, context) + has_more = args.adapter.mentions_has_more(list, items, args.config, context) else - mentions_cache.in_progress = #list.items ~= 0 and #mentions_cache.items < args.config.limit + -- A page that is not full is the last one. Asking for the next page would wait for an empty one. + has_more = #list.items >= page_size end + if #items >= limit then + has_more = false + for i = #items, limit + 1, -1 do + items[i] = nil + end + end + entry.items = items - args.callback({ items = mentions_cache.items, isIncomplete = mentions_cache.in_progress }) - - if mentions_cache.in_progress then - fetch_mentions(page + 1, context) + if not has_more then + finish(true) + return end + + notify(fetch.subscribers, { items = items, isIncomplete = true }) + fetch_page(page + 1, context) end, request.handle_item, request.handle_parsed) - if job then - state.active = job - job:start() - else - args.cache[bufnr] = nil + if not job then + finish(false) + return end + fetch.active = job + job:start() end local preflight = args.adapter.mentions_preflight_request and args.adapter.mentions_preflight_request(args.git_info, args.trigger_char, args.config) - if not preflight then - fetch_mentions(1, nil) - return controller + fetch_page(1, nil) + return end local job = runner.build_fallback(preflight.commands, function(result, success) - if state.cancelled then + if fetch.cancelled then return end + -- `handle_result` also gets a failed preflight, and chooses the users to ask for local context = nil if preflight.handle_result then context = preflight.handle_result(result, success) end - fetch_mentions(1, context) + fetch_page(1, context) end) - if job then - state.active = job - job:start() - else - args.cache[bufnr] = nil + if not job then + fetch_page(1, nil) + return + end + fetch.active = job + 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 { 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 }) + return nil + end + + if entry then + if #entry.items > 0 then + args.callback({ items = entry.items, isIncomplete = true }) + end + return subscribe(args.cache, bufnr, entry, entry.fetch --[[@as cmp_git.Provider.MentionFetch]], args.callback) 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) return controller end diff --git a/lua/cmp_git/provider/capabilities/trailer_lines.lua b/lua/cmp_git/provider/capabilities/trailer_lines.lua new file mode 100644 index 0000000..b221301 --- /dev/null +++ b/lua/cmp_git/provider/capabilities/trailer_lines.lua @@ -0,0 +1,90 @@ +---The checks of the line at the cursor for trailer completion. Core runs them on each request, and they are apart from +---the trailer capability, so that a request that trailer completion declines does not load the log parsers. +local M = {} + +---@param filetypes string[] +---@param filetype string +---@return boolean +local function filetype_matches(filetypes, filetype) + if vim.tbl_contains(filetypes, filetype) then + return true + end + + for ft in string.gmatch(filetype, "[^%.]+") do + if vim.tbl_contains(filetypes, ft) then + return true + end + end + + return false +end + +---@param keys string[] +---@param key string +---@return boolean +local function contains_key(keys, key) + key = key:lower() + for _, candidate in ipairs(keys) do + if candidate:lower() == key then + return true + end + end + return false +end + +---@param line string? +---@return string? +function M.line_key(line) + return line and string.match(line, "^([%w-]+):") +end + +---The trigger character to give to the identities and values formats: `:` when the value touches the colon, else +---nil. +---@param line string? +---@return string? +function M.attached_trigger(line) + return line and string.match(line, "^[%w-]+(:)%S*$") +end + +---Trailer completion is only offered in commit messages, from `config.min_line` (by default below the subject and +---its blank line), at the start of a trailer block or after another trailer. +---@param config cmp_git.Config.GitTrailers +---@param context { bufnr?: integer, cursor?: { row?: integer }, line?: string } +---@param kind "keys" | "identities" | "values" +---@return boolean +function M.available(config, context, kind) + local row = context.cursor and context.cursor.row + if not row or row < (config.min_line or 1) then + return false + end + + -- Core asks each kind on each request, so the checks of the line come before the reads of the buffer + if kind == "identities" then + local key = M.line_key(context.line) + if key == nil or not contains_key(config.identities.keys or {}, key) then + return false + end + elseif kind == "values" then + -- `Key:` alone falls through to the `:` trigger, so `Fixes:` still completes commits + if M.line_key(context.line) == nil or string.match(context.line, "^[%w-]+:$") then + return false + end + end + + local bufnr = context.bufnr or vim.api.nvim_get_current_buf() + if not vim.api.nvim_buf_is_valid(bufnr) then + return false + end + + -- `vim.bo[bufnr]` makes a new table on each read + local filetype = vim.api.nvim_get_option_value("filetype", { buf = bufnr }) + if not filetype_matches(config.filetypes or {}, filetype) then + return false + end + + -- Line 1 has no previous line, so it counts as blank. A negative index would count from the buffer end. + local previous = row > 1 and vim.api.nvim_buf_get_lines(bufnr, row - 2, row - 1, false)[1] or "" + return previous:match("^%s*$") ~= nil or previous:match("^[%w-]+:%s*%S") ~= nil +end + +return M diff --git a/lua/cmp_git/provider/capabilities/trailers.lua b/lua/cmp_git/provider/capabilities/trailers.lua index 3df9c70..881a21c 100644 --- a/lua/cmp_git/provider/capabilities/trailers.lua +++ b/lua/cmp_git/provider/capabilities/trailers.lua @@ -1,10 +1,17 @@ local command = require("cmp_git.command") local format = require("cmp_git.format") +local trailer_lines = require("cmp_git.provider.capabilities.trailer_lines") local utils = require("cmp_git.utils") local UNIT_SEPARATOR = "\031" local KEY_PATTERN = "^[%w-]+$" +local find = string.find +local lower = string.lower +local match = string.match +local sub = string.sub +local trim_range = utils.trim_range + local M = { keys = {}, identities = {}, @@ -18,101 +25,72 @@ local M = { ---@field timestamp integer? ---@field diff number Seconds since the most recent use. ----@param filetypes string[] ----@param filetype string ----@return boolean -local function filetype_matches(filetypes, filetype) - if vim.tbl_contains(filetypes, filetype) then - return true - end - - for ft in string.gmatch(filetype, "[^%.]+") do - if vim.tbl_contains(filetypes, ft) then - return true +---Calls `on_field` for each field of `output`, a `git log` output whose records end with NUL and whose fields end +---with the unit separator or the NUL of the record. Each record has at least one field, which can be empty. A +---trailing record without a NUL is dropped, like `utils.split_nul` does. The fields are read in place, so that a +---parse makes no table or string for each record. +---@param output string +---@param on_field fun(first: integer, last: integer, index: integer) The field is `output:sub(first, last)`, and +---`index` counts the fields of the record from 1. +local function each_field(output, on_field) + local first = 1 + local index = 1 + local record_end = find(output, "\0", 1, true) + -- The next separator at or after `first`. It can be in a later record, so it is kept until a field reaches it, + -- and each byte is searched once. + local separator = 0 + + while record_end do + if separator < first then + separator = find(output, UNIT_SEPARATOR, first, true) or math.huge end - end - return false -end - ----@param keys string[] ----@param key string ----@return boolean -local function contains_key(keys, key) - key = key:lower() - for _, candidate in ipairs(keys) do - if candidate:lower() == key then - return true + if separator < record_end then + on_field(first, separator - 1, index) + first = separator + 1 + index = index + 1 + else + on_field(first, record_end - 1, index) + first = record_end + 1 + index = 1 + record_end = find(output, "\0", first, true) end end - return false end +---Parses the identity that `^%s*(.-)%s*<([^>]+)>` would match, without the whitespace around the name and the email. +---Unlike the pattern, which is quadratic on long runs of whitespace, it takes linear time. ---@param value string ----@return string? name ----@return string? email +---@return string? name nil when there is no identity. The name and the email can be empty. +---@return string email local function parse_ident(value) - local name, email = string.match(value, "^%s*(.-)%s*<([^>]+)>") - if not name or name == "" or utils.trim(email) == "" then - return nil, nil - end - return name, utils.trim(email) -end + local from = 1 ----@param line string? ----@return string? -function M.line_key(line) - return line and string.match(line, "^([%w-]+):") -end - ----The trigger character to give to the identities and values formats: `:` when the value touches the colon, else ----nil. ----@param line string? ----@return string? -function M.attached_trigger(line) - return line and string.match(line, "^[%w-]+(:)%S*$") -end - ----Trailer completion is only offered in commit messages, from `config.min_line` (by default below the subject and ----its blank line), at the start of a trailer block or after another trailer. ----@param config cmp_git.Config.GitTrailers ----@param context { bufnr?: integer, cursor?: { row?: integer }, line?: string } ----@param kind "keys" | "identities" | "values" ----@return boolean -function M.available(config, context, kind) - local bufnr = context.bufnr or vim.api.nvim_get_current_buf() - if not vim.api.nvim_buf_is_valid(bufnr) then - return false - end - - if not filetype_matches(config.filetypes or {}, vim.bo[bufnr].filetype) then - return false - end - - local row = context.cursor and context.cursor.row - if not row or row < (config.min_line or 1) then - return false - end + while true do + local open = find(value, "<", from, true) + if not open then + return nil, "" + end - -- Line 1 has no previous line, so it counts as blank. A negative index would count from the buffer end. - local previous = row > 1 and vim.api.nvim_buf_get_lines(bufnr, row - 2, row - 1, false)[1] or "" - if not previous:match("^%s*$") and not previous:match("^[%w-]+:%s*%S") then - return false - end + local close = find(value, ">", open + 1, true) + -- A later `<` has no `>` after it either + if not close then + return nil, "" + end - if kind == "identities" then - local key = M.line_key(context.line) - return key ~= nil and contains_key(config.identities.keys or {}, key) - end + -- `[^>]+` needs one character, so `<>` is part of the name, and a later `<` can start the email + if close > open + 1 then + return sub(value, trim_range(value, 1, open - 1)), sub(value, trim_range(value, open + 1, close - 1)) + end - if kind == "values" then - -- `Key:` alone falls through to the `:` trigger, so `Fixes:` still completes commits - return M.line_key(context.line) ~= nil and not string.match(context.line, "^[%w-]+:$") + from = close + 1 end - - return true end +M.line_key = trailer_lines.line_key +M.attached_trigger = trailer_lines.attached_trigger +M.available = trailer_lines.available + ---@param config cmp_git.Config.GitTrailerKeys ---@return cmp_git.CommandSpec function M.keys.request(config) @@ -148,21 +126,32 @@ function M.keys.parse(result, config) end end - for _, record in ipairs(utils.split_nul(result)) do - for _, key in ipairs(vim.split(record, UNIT_SEPARATOR, { plain = true })) do - key = utils.trim(key) - -- Skips git versions that print the unknown `%(trailers:keyonly)` placeholder as text - if key:match(KEY_PATTERN) then - local trailer = by_key[key:lower()] - if not trailer then - trailer = { key = key, count = 0 } - by_key[key:lower()] = trailer - table.insert(keys, trailer) - end - trailer.count = trailer.count + 1 - end + -- The lowercase form of each key as written, so that each casing is lowered once + local ids = {} + + each_field(result, function(first, last) + first, last = trim_range(result, first, last) + local _, key_last = find(result, "^[%w-]+", first) + -- Skips git versions that print the unknown `%(trailers:keyonly)` placeholder as text + if first > last or key_last ~= last then + return + end + + local key = sub(result, first, last) + local id = ids[key] + if not id then + id = lower(key) + ids[key] = id end - end + + local trailer = by_key[id] + if not trailer then + trailer = { key = key, count = 0 } + by_key[id] = trailer + keys[#keys + 1] = trailer + end + trailer.count = trailer.count + 1 + end) return keys end @@ -208,21 +197,25 @@ function M.identities.parse(log_result, ident_result) local now = os.time() local identities = {} local by_email = {} + -- The log repeats a few identities many times, and a repeat from the log changes nothing. These skip a repeat + -- before the parse of its trailer value or the lowercase copy of its email. + local known_values = {} + local known_emails = {} - ---@param name string - ---@param email string + ---@param name string Without surrounding whitespace. + ---@param email string Without surrounding whitespace. ---@param timestamp integer? ---@param source string ---@param is_self boolean local function add(name, email, timestamp, source, is_self) - name = utils.trim(name) - email = utils.trim(email) - if name == "" or email == "" then + if name == "" or email == "" or (known_emails[email] and not is_self) then return end + known_emails[email] = true -- The log is newest first, so the first record for an email has the most recent name - local existing = by_email[email:lower()] + local id = lower(email) + local existing = by_email[id] if existing then existing.is_self = existing.is_self or is_self return @@ -239,28 +232,51 @@ function M.identities.parse(log_result, ident_result) source = source, -- "author", the trailer key, or "self" is_self = is_self, } - by_email[email:lower()] = entry - table.insert(identities, entry) + by_email[id] = entry + identities[#identities + 1] = entry end - for _, record in ipairs(utils.split_nul(log_result)) do - local fields = vim.split(record, UNIT_SEPARATOR, { plain = true }) - if #fields >= 3 then - local timestamp = tonumber(utils.trim(fields[1])) - add(fields[2], fields[3], timestamp, "author", false) - - for i = 4, #fields do - local key, value = string.match(fields[i], "^%s*([%w-]+):%s*(.-)%s*$") - local name, email = parse_ident(value or "") - if name and email then - add(name, email, timestamp, key, false) - end - end + -- The fields of the current record: the time, the author name and email, then the trailers + local time_first, time_last, author_first, author_last = 1, 0, 1, 0 + local timestamp + + ---@param first integer + ---@param last integer + local function add_trailer(first, last) + local key, value_first = match(log_result, "^%s*([%w-]+):()", first) + if not key then + return + end + + local value = sub(log_result, value_first, last) + if known_values[value] then + return + end + known_values[value] = true + + local name, email = parse_ident(value) + if name then + add(name, email, timestamp, key, false) end end + each_field(log_result, function(first, last, index) + if index == 1 then + time_first, time_last = first, last + elseif index == 2 then + author_first, author_last = first, last + elseif index == 3 then + -- `tonumber` ignores the newline that ends the previous record + timestamp = tonumber(sub(log_result, time_first, time_last)) + local name = sub(log_result, trim_range(log_result, author_first, author_last)) + add(name, sub(log_result, trim_range(log_result, first, last)), timestamp, "author", false) + else + add_trailer(first, last) + end + end) + local name, email = parse_ident(ident_result) - if name and email then + if name then add(name, email, nil, "self", true) end @@ -287,34 +303,51 @@ function M.values.parse(result) local now = os.time() local by_key = {} local by_value = {} + -- The lowercase form of each key as written, so that each casing is lowered once + local ids = {} + local timestamp + + each_field(result, function(first, last, index) + if index == 1 then + -- `tonumber` ignores the newline that ends the previous record + timestamp = tonumber(sub(result, first, last)) + return + end - for _, record in ipairs(utils.split_nul(result)) do - local fields = vim.split(record, UNIT_SEPARATOR, { plain = true }) - local timestamp = tonumber(utils.trim(fields[1] or "")) - - for i = 2, #fields do - local key, value = string.match(fields[i], "^%s*([%w-]+):%s*(.-)%s*$") - if key and value ~= "" then - local id = key:lower() - by_key[id] = by_key[id] or {} - by_value[id] = by_value[id] or {} - - local entry = by_value[id][value] - if not entry then - entry = { - key = key, - value = value, - count = 0, - timestamp = timestamp, - diff = timestamp and os.difftime(now, timestamp) or 0, - } - by_value[id][value] = entry - table.insert(by_key[id], entry) - end - entry.count = entry.count + 1 - end + local key, value_first = match(result, "^%s*([%w-]+):()", first) + if not key then + return + end + local value_last + value_first, value_last = trim_range(result, value_first, last) + if value_first > value_last then + return + end + + local id = ids[key] + if not id then + id = lower(key) + ids[key] = id + by_key[id] = by_key[id] or {} + by_value[id] = by_value[id] or {} end - end + + local value = sub(result, value_first, value_last) + local entry = by_value[id][value] + if not entry then + entry = { + key = key, + value = value, + count = 0, + timestamp = timestamp, + diff = timestamp and os.difftime(now, timestamp) or 0, + } + by_value[id][value] = entry + local values = by_key[id] + values[#values + 1] = entry + end + entry.count = entry.count + 1 + end) return by_key end @@ -397,13 +430,27 @@ function M.keys.job(args) end local bufnr = args.bufnr or vim.api.nvim_get_current_buf() - local runner = args.runner or command - return runner.build(M.keys.request(args.config), function(result, success) - -- A failed log (for example, a repository without commits) still gives the built-in keys + + ---@param result string + ---@param success boolean + local function finish(result, success) + -- A failed log (for example, a repository without commits) still gives the built-in keys. They are not + -- cached, so that the next request tries the log again. local parsed = M.keys.parse(success and result or "", args.config) - args.cache[bufnr] = parsed + if success then + args.cache[bufnr] = parsed + end args.callback({ items = items(parsed, args.config, args.trigger_char), isIncomplete = false }) - end) + end + + local spec = M.keys.request(args.config) + spec.bufnr = bufnr + local runner = args.runner or command + local job = runner.build(spec, finish) + if not job then + finish("", false) + end + return job end ---@param args cmp_git.Provider.TrailerIdentityArgs @@ -415,50 +462,62 @@ function M.identities.job(args) local bufnr = args.bufnr or vim.api.nvim_get_current_buf() local runner = args.runner or command - local state = { cancelled = false, active = nil } - - local function finish(log_result, ident_result) - local parsed = M.identities.parse(log_result, ident_result) - args.cache[bufnr] = parsed + local state = { cancelled = false, pending = 0 } + local log = { result = "", success = false } + local ident = { result = "", success = false } + + -- A failed log (for example, a repository without commits) still gives your identity, and a failed `git var` still + -- gives the log. Only a result of both is cached, so that the next request tries a failed command again. + local function finish() + local parsed = M.identities.parse(log.success and log.result or "", ident.success and ident.result or "") + if log.success and ident.success then + args.cache[bufnr] = parsed + end args.callback({ items = items(parsed, args.config, args.trigger_char), isIncomplete = false }) end - -- A failed log (for example, a repository without commits) still gives your identity - local log_job = runner.build(M.identities.request(args.config), function(log_result, log_success) - if state.cancelled then - return - end - - log_result = log_success and log_result or "" - local ident_job = runner.build(M.identities.ident_request(), function(ident_result, ident_success) - if state.cancelled then - return + ---@type cmp_git.SystemJob[] + local jobs = {} + + ---@param spec cmp_git.CommandSpec + ---@param answer { result: string, success: boolean } + local function build(spec, answer) + spec.bufnr = bufnr + local job = runner.build(spec, function(result, success) + answer.result, answer.success = result, success + state.pending = state.pending - 1 + if state.pending == 0 and not state.cancelled then + finish() end - finish(log_result, ident_success and ident_result or "") end) - - if ident_job then - state.active = ident_job - ident_job:start() - else - finish(log_result, "") + if job then + state.pending = state.pending + 1 + table.insert(jobs, job) end - end) + end - if not log_job then + -- The commands do not depend on each other, so they run at once, and the list waits for both + build(M.identities.request(args.config), log) + build(M.identities.ident_request(), ident) + + if #jobs == 0 then + finish() return nil end return { - command = log_job.command, + command = jobs[1].command, start = function() - state.active = log_job - log_job:start() + for _, job in ipairs(jobs) do + job:start() + end end, cancel = function() state.cancelled = true - if state.active and state.active.cancel then - state.active:cancel() + for _, job in ipairs(jobs) do + if job.cancel then + job:cancel() + end end end, } @@ -480,12 +539,25 @@ function M.values.job(args) return nil end + local spec = M.values.request(args.config) + spec.bufnr = bufnr local runner = args.runner or command - return runner.build(M.values.request(args.config), function(result, success) - local parsed = M.values.parse(success and result or "") + local job = runner.build(spec, function(result, success) + -- A failure is not cached, so that the next request tries again + if not success then + respond({}) + return + end + + local parsed = M.values.parse(result) args.cache[bufnr] = parsed respond(parsed) end) + + if not job then + respond({}) + end + return job end ---@param args cmp_git.Provider.TrailerKeyArgs diff --git a/lua/cmp_git/repository/init.lua b/lua/cmp_git/repository/init.lua index acb7c01..b4f4b15 100644 --- a/lua/cmp_git/repository/init.lua +++ b/lua/cmp_git/repository/init.lua @@ -2,52 +2,315 @@ local remote_url = require("cmp_git.repository.remote_url") local M = {} ----@param cmd string ----@param opts { on_complete: fun(success: boolean, output: string[]): nil; cwd?: string } +---@alias cmp_git.GitInfo cmp_git.RepositoryMetadata + +---@alias cmp_git.RemoteUrlKind "configured" | "rewritten" + +---What discovery found for one buffer. A field is nil until the git process that gives it exits. +---@class cmp_git.RepositoryState +---@field dir string? The buffer directory when discovery started. +---@field nvim_cwd string Neovim's cwd when discovery started. +---@field cwd string? The directory in which discovery found the repository. +---@field is_repo boolean? +---@field checked_at number? When discovery found no repository, in `vim.uv.now()` milliseconds. +---@field config_path string? The config file of the repository. +---@field config_stamp string? The stamp of the config file when discovery found the repository. +---@field remote_urls table> The URL of each remote, by remote name. +---@field waiting table The callbacks that wait for a running git process, by step. + +-- `git init` in the directory or in any parent makes a directory part of a repository, and one stat cannot see +-- that, so discovery checks a directory outside a repository again after this time +local NOT_A_REPOSITORY_TTL_MS = 5000 + +---The discovery state of each buffer. The buffer directory and Neovim's cwd are part of the key, see `matches`. +---@type table +local states = {} + +vim.api.nvim_create_autocmd("BufWipeout", { + group = vim.api.nvim_create_augroup("cmp_git.repository", { clear = true }), + desc = "Forget the cmp-git repository of the buffer", + callback = function(args) + states[args.buf] = nil + end, +}) + +---@param cmd string[] +---@param cwd string +---@param on_complete fun(success: boolean, stdout: string): nil ---@return nil -local function run_cmd_async(cmd, opts) - vim.system({ "sh", "-c", cmd }, { - text = true, - cwd = opts.cwd, - }, function(result) +local function run_async(cmd, cwd, on_complete) + local ok = pcall(vim.system, cmd, { text = true, cwd = cwd }, function(result) vim.schedule(function() - local output = vim.split(result.stdout or "", "\n", { trimempty = true }) - opts.on_complete(result.code == 0, output) + on_complete(result.code == 0, result.stdout or "") end) end) + if not ok then + -- `vim.system` throws when git is not installed or when `cwd` was removed + vim.schedule(function() + on_complete(false, "") + end) + end +end + +---@param bufnr integer? +---@return integer +local function resolve_bufnr(bufnr) + if bufnr == nil or bufnr == 0 then + return vim.api.nvim_get_current_buf() + end + return bufnr +end + +---@param bufnr integer +---@return string? dir The directory of the buffer file, nil when it has none or the directory does not exist. +local function buffer_dir(bufnr) + if vim.bo[bufnr].filetype == "octo" then + return nil + end + + local name = vim.api.nvim_buf_get_name(bufnr) + if name == "" then + return nil + end + + local dir = vim.fn.fnamemodify(name, ":p:h") + -- `vim.system` throws for a `cwd` that does not exist, for example for a new file in a new directory + if vim.fn.isdirectory(dir) ~= 1 then + return nil + end + return dir +end + +---@param path string? +---@return string? stamp A value that changes when git writes the file. +local function file_stamp(path) + local stat = path and vim.uv.fs_stat(path) + if not stat then + return nil + end + return string.format("%d.%d:%d:%d", stat.mtime.sec, stat.mtime.nsec, stat.ino, stat.size) +end + +---@param state cmp_git.RepositoryState +---@param dir string? +---@return boolean +local function matches(state, dir) + if state.dir ~= dir then + return false + end + + -- Neovim's cwd matters unless the repository is in the buffer directory + return (dir ~= nil and state.cwd == dir) or state.nvim_cwd == vim.fn.getcwd() end +---The directory in which the commands for the buffer run. It always exists. +---@param bufnr? integer The buffer, by default the current one. ---@return string -function M.get_cwd() - if vim.fn.getreg("%") ~= "" and vim.bo.filetype ~= "octo" then - return vim.fn.expand("%:p:h") +function M.get_cwd(bufnr) + bufnr = resolve_bufnr(bufnr) + -- The buffer of a request can be wiped while the request waits for git + if not vim.api.nvim_buf_is_valid(bufnr) then + return vim.fn.getcwd() end - return vim.fn.getcwd() + local dir = buffer_dir(bufnr) + local state = states[bufnr] + -- The commands must see the repository that discovery found, which can be in Neovim's cwd + if state ~= nil and state.cwd ~= nil and matches(state, dir) then + return state.cwd + end + return dir or vim.fn.getcwd() end ----@alias cmp_git.GitInfo cmp_git.RepositoryMetadata +---@param state cmp_git.RepositoryState +---@return boolean +local function is_fresh(state) + if state.is_repo == false then + return vim.uv.now() - state.checked_at < NOT_A_REPOSITORY_TTL_MS + end + if state.is_repo == true then + -- Git writes the config file when a remote is added, removed, or changed + return file_stamp(state.config_path) == state.config_stamp + end + return true +end + +---@param bufnr integer +---@return cmp_git.RepositoryState +local function get_state(bufnr) + local dir = buffer_dir(bufnr) + local state = states[bufnr] + if state ~= nil and matches(state, dir) and is_fresh(state) then + return state + end + + state = { dir = dir, nvim_cwd = vim.fn.getcwd(), remote_urls = {}, waiting = {} } + states[bufnr] = state + return state +end + +---Run `step` and then call `callback`. A call while the step runs waits for the same run, so that concurrent +---requests start one process. +---@param state cmp_git.RepositoryState +---@param name string +---@param step fun(state: cmp_git.RepositoryState, done: fun()): nil +---@param callback fun(): nil +---@return nil +local function run_step(state, name, step, callback) + local waiting = state.waiting[name] + if waiting ~= nil then + table.insert(waiting, callback) + return + end + + waiting = { callback } + state.waiting[name] = waiting + step(state, function() + state.waiting[name] = nil + for _, waiter in ipairs(waiting) do + waiter() + end + end) +end + +---@param path string +---@return boolean +local function is_absolute(path) + return path:sub(1, 1) == "/" or path:match("^%a:[/\\]") ~= nil +end + +---Find the repository of the buffer directory, or else of Neovim's cwd. +---@param state cmp_git.RepositoryState +---@param done fun() +---@return nil +local function find_repository(state, done) + local candidates = {} ---@type string[] + if state.dir ~= nil then + table.insert(candidates, state.dir) + end + if state.nvim_cwd ~= state.dir then + table.insert(candidates, state.nvim_cwd) + end + + local index = 0 + local function check_next() + index = index + 1 + local cwd = candidates[index] + if cwd == nil then + state.is_repo = false + state.checked_at = vim.uv.now() + done() + return + end + + local cmd = { "git", "rev-parse", "--is-inside-work-tree", "--is-inside-git-dir", "--git-common-dir" } + run_async(cmd, cwd, function(success, stdout) + local lines = vim.split(stdout, "\n", { trimempty = true }) + -- Inside `.git`, only the second line (`--is-inside-git-dir`) is true + if not success or (lines[1] ~= "true" and lines[2] ~= "true") or lines[3] == nil then + check_next() + return + end + + -- A linked worktree has its config in the common directory. The path is relative to `cwd`. + local common_dir = is_absolute(lines[3]) and lines[3] or (cwd .. "/" .. lines[3]) + state.cwd = cwd + state.config_path = common_dir .. "/config" + state.config_stamp = file_stamp(state.config_path) + state.is_repo = true + done() + end) + end + check_next() +end + +---@param state cmp_git.RepositoryState +---@param callback fun(): nil +---@return nil +local function with_repository(state, callback) + if state.is_repo ~= nil then + callback() + return + end + run_step(state, "repository", find_repository, callback) +end + +---@param stdout string The output of `git config -z --get-regexp`. +---@return table +local function parse_configured_urls(stdout) + local urls = {} + for entry in vim.gsplit(stdout, "\0", { plain = true, trimempty = true }) do + local key, url = entry:match("^([^\n]*)\n(.*)$") + local name = key and key:match("^remote%.(.+)%.url$") + if name ~= nil then + -- The last URL wins, as with `git config --get` + urls[name] = url + end + end + return urls +end + +---@param stdout string The output of `git remote -v`. +---@return table +local function parse_rewritten_urls(stdout) + local urls = {} + for line in vim.gsplit(stdout, "\n", { plain = true, trimempty = true }) do + -- The fetch URL is the one that `git remote get-url` prints + local name, url = line:match("^(%S+)\t(.*) %(fetch%)$") + if name ~= nil then + urls[name] = url + end + end + return urls +end + +---@param state cmp_git.RepositoryState +---@param kind cmp_git.RemoteUrlKind +---@param callback fun(urls: table): nil +---@return nil +local function with_remote_urls(state, kind, callback) + local function call() + callback(state.remote_urls[kind]) + end + if state.remote_urls[kind] ~= nil then + call() + return + end + + run_step(state, kind, function(_, done) + -- One process gives the URLs of all remotes. Only `git remote` applies the `url..insteadOf` rewrites. + local cmd = { "git", "config", "-z", "--get-regexp", [[^remote\..*\.url$]] } + local parse = parse_configured_urls + if kind == "rewritten" then + cmd = { "git", "remote", "-v" } + parse = parse_rewritten_urls + end + + run_async(cmd, state.cwd, function(_, stdout) + state.remote_urls[kind] = parse(stdout) + done() + end) + end, call) +end ---@param on_result fun(is_git_repo: boolean): nil +---@param bufnr? integer The buffer, by default the current one. ---@return nil -function M.is_git_repo(on_result) - local cwd = M.get_cwd() ---@type string? - local function check_in_git_repo() - local cmd = "git rev-parse --is-inside-work-tree --is-inside-git-dir" - run_cmd_async(cmd, { - on_complete = function(success, output) - -- Inside `.git`, only the second line (`--is-inside-git-dir`) is true - local is_git_repo = success and vim.tbl_contains(output, "true") - if not is_git_repo and cwd ~= nil then - cwd = nil - check_in_git_repo() - return - end - on_result(is_git_repo) - end, - cwd = cwd, - }) +function M.is_git_repo(on_result, bufnr) + bufnr = resolve_bufnr(bufnr) + if not vim.api.nvim_buf_is_valid(bufnr) then + on_result(false) + return end - check_in_git_repo() + -- An octo buffer gets its repository from its name + if vim.bo[bufnr].filetype == "octo" then + on_result(true) + return + end + + local state = get_state(bufnr) + with_repository(state, function() + on_result(state.is_repo == true) + end) end ---@param metadata cmp_git.RepositoryMetadata @@ -58,16 +321,70 @@ local function apply_ssh_aliases(metadata, ssh_aliases) return metadata end - for alias, host in pairs(ssh_aliases) do - metadata.host = metadata.host:gsub("^" .. alias:gsub("%-", "%%-"):gsub("%.", "%%.") .. "$", host, 1) + -- A plain lookup, because an alias or a host can have Lua pattern characters, such as `+` or `%` + local host = ssh_aliases[metadata.host] + if host ~= nil then + metadata.host = host + end + + return metadata +end + +---Give the metadata the host entry of an instance under a path prefix, such as `example.com/gitlab`. The URL path of +---such an instance starts with the prefix, which is then not part of the owner. +---@param metadata cmp_git.RepositoryMetadata +---@param hosts string[]? The configured hosts. +---@return cmp_git.RepositoryMetadata +local function apply_host_prefixes(metadata, hosts) + local host, owner = metadata.host, metadata.owner + if host == nil or owner == nil or hosts == nil then + return metadata + end + + -- Plain comparisons, because a host can have Lua pattern characters, such as `.`, `-`, or `[` + local host_slash = host .. "/" + local has_plain_host = false + local prefixed_entry ---@type string? + local has_other_prefixed_entries = false + local match_prefix ---@type string? + for _, entry in ipairs(hosts) do + if entry == host then + has_plain_host = true + elseif #entry > #host_slash and entry:sub(1, #host_slash) == host_slash then + -- The GitHub and GitLab lists can both have the same entry + if prefixed_entry == nil then + prefixed_entry = entry + elseif entry ~= prefixed_entry then + has_other_prefixed_entries = true + end + + local prefix = entry:sub(#host_slash + 1) + -- The longest prefix wins, and an owner must be left after it + if + #owner > #prefix + 1 + and owner:sub(1, #prefix + 1) == prefix .. "/" + and (match_prefix == nil or #prefix > #match_prefix) + then + match_prefix = prefix + end + end + end + + if match_prefix ~= nil then + metadata.host = host_slash .. match_prefix + metadata.owner = owner:sub(#match_prefix + 2) + elseif prefixed_entry ~= nil and not has_other_prefixed_entries and not has_plain_host then + -- The path of an SSH remote has no prefix, for example `git@example.com:group/repo.git` + metadata.host = prefixed_entry end return metadata end +---@param bufnr integer ---@return cmp_git.RepositoryMetadata? -local function discover_octo() - if vim.bo.filetype ~= "octo" then +local function discover_octo(bufnr) + if vim.bo[bufnr].filetype ~= "octo" then return nil end @@ -76,80 +393,63 @@ local function discover_octo() host = "github.com" end - local filename = vim.fn.expand("%:p:h") - local owner, repo = string.match(filename, "^octo://([^/]+)/([^/]+)") + local owner, repo = string.match(vim.api.nvim_buf_get_name(bufnr), "^octo://([^/]+)/([^/]+)") return { host = host, owner = owner, repo = repo } end +---@class cmp_git.DiscoverOptions +---@field enableRemoteUrlRewrites boolean +---@field ssh_aliases {[string]: string} +---@field hosts? string[] The configured GitHub and GitLab hosts. An entry can have a path prefix. +---@field on_complete fun(metadata: cmp_git.RepositoryMetadata): nil +---@field bufnr? integer The buffer, by default the current one. + +---The repository of the first remote in `remotes` that has a supported URL. The result is a new table for each +---call, because callers change it. ---@param remotes string|string[] ----@param opts {enableRemoteUrlRewrites: boolean, ssh_aliases: {[string]: string}, on_complete: fun(metadata: cmp_git.RepositoryMetadata): nil} +---@param opts cmp_git.DiscoverOptions ---@return nil function M.discover(remotes, opts) opts = opts or {} - local cwd = M.get_cwd() ---@type string? - - local discover ---@type fun(): nil + if type(remotes) == "string" then + remotes = { remotes } + end + local bufnr = resolve_bufnr(opts.bufnr) ---@param metadata cmp_git.RepositoryMetadata - local function handle_metadata(metadata) - if metadata.host == nil and cwd ~= nil then - cwd = nil - discover() - return - end + local function complete(metadata) + opts.on_complete(apply_host_prefixes(apply_ssh_aliases(metadata, opts.ssh_aliases), opts.hosts)) + end - opts.on_complete(apply_ssh_aliases(metadata, opts.ssh_aliases)) + if not vim.api.nvim_buf_is_valid(bufnr) then + complete({}) + return end - discover = function() - if type(remotes) == "string" then - remotes = { remotes } - end + local octo_metadata = discover_octo(bufnr) + if octo_metadata ~= nil then + complete(octo_metadata) + return + end - local octo_metadata = discover_octo() - if octo_metadata ~= nil then - handle_metadata(octo_metadata) + local state = get_state(bufnr) + with_repository(state, function() + if not state.is_repo then + complete({}) return end - local remote_index = 1 - local function check_remote() - if remote_index > #remotes then - handle_metadata({}) - return - end - - local remote = remotes[remote_index] - local cmd ---@type string - if opts.enableRemoteUrlRewrites then - cmd = "git remote get-url " .. remote - else - cmd = "git config --get remote." .. remote .. ".url" + with_remote_urls(state, opts.enableRemoteUrlRewrites and "rewritten" or "configured", function(urls) + for _, remote in ipairs(remotes) do + local metadata = remote_url.parse(urls[remote]) + if metadata.host ~= nil and metadata.owner ~= nil and metadata.repo ~= nil then + complete(metadata) + return + end end - - run_cmd_async(cmd, { - on_complete = function(success, output) - remote_index = remote_index + 1 - if not success then - check_remote() - return - end - - local metadata = remote_url.parse(output[1]) - if metadata.host ~= nil and metadata.owner ~= nil and metadata.repo ~= nil then - handle_metadata(metadata) - return - end - - check_remote() - end, - cwd = cwd, - }) - end - check_remote() - end - - discover() + complete({}) + end) + end) end return M diff --git a/lua/cmp_git/repository/remote_url.lua b/lua/cmp_git/repository/remote_url.lua index 4d2704a..6ec617f 100644 --- a/lua/cmp_git/repository/remote_url.lua +++ b/lua/cmp_git/repository/remote_url.lua @@ -5,26 +5,64 @@ local M = {} ---@field owner string? ---@field repo string? +---@param authority string `[user[:password]@]host[:port]` +---@return string? host +---@return string? port Empty when the authority has no port. +local function split_authority(authority) + -- Credentials are not part of the host + authority = authority:gsub("^.*@", "") + + local host, port = authority:match("^(%[.*%]):?(%d*)$") + if host == nil then + host, port = authority:match("^([^:]*):?(%d*)$") + end + return host, port +end + ---@param remote_url string? ---@return cmp_git.RepositoryMetadata function M.parse(remote_url) - if remote_url == nil or remote_url == "" then + if remote_url == nil then return {} end - local clean_remote_url = remote_url:gsub("%.git", ""):gsub("%s", "") - local host, owner, repo = string.match(clean_remote_url, "^git.*@(.+):(.+)/(.+)$") + local url = vim.trim(remote_url) + local host, port, path ---@type string?, string?, string? + local scheme, rest = url:match("^(%a[%w+.-]*)://(.*)$") + if scheme ~= nil then + scheme = scheme:lower() + if scheme == "file" then + return {} + end - if host == nil then - host, owner, repo = string.match(clean_remote_url, "^https?://(.+)/(.+)/(.+)$") + local authority + authority, path = rest:match("^([^/]*)(.*)$") + host, port = split_authority(authority) + -- The API of an http(s) remote is on the same origin. Other ports, such as the one of an `ssh://` remote, + -- are not the port of the API. + if host ~= nil and port ~= "" and (scheme == "http" or scheme == "https") then + host = host .. ":" .. port + end + else + -- Git reads `[user@]host:path` as an scp-like ssh URL when no slash comes before the first colon. Anything + -- else is a local path. + local authority + authority, path = url:match("^([^/:]+):(.*)$") + if authority == nil then + return {} + end + host = authority:gsub("^.*@", "") end - if host == nil then - host, owner, repo = string.match(clean_remote_url, "^ssh://git@([^:]+):*.*/(.+)/(.+)$") + if host == nil or host == "" or path == nil then + return {} end - if host == nil then - host, owner, repo = string.match(clean_remote_url, "^([^:]+):(.+)/(.+)$") + path = path:gsub("/+$", ""):gsub("%.git$", ""):gsub("^/+", ""):gsub("//+", "/") + -- The owner is all segments before the last one, for example a GitLab group and its subgroups + local owner, repo = path:match("^(.+)/([^/]+)$") + if owner == nil then + return {} end return { host = host, owner = owner, repo = repo } diff --git a/lua/cmp_git/response.lua b/lua/cmp_git/response.lua index 1e85de7..9d9cef0 100644 --- a/lua/cmp_git/response.lua +++ b/lua/cmp_git/response.lua @@ -5,42 +5,42 @@ local M = {} ---@generic TItem ---@param response string ---@param handle_item fun(item: TItem): cmp_git.CompletionItem ----@param handle_parsed? fun(parsed: any): TItem[] ----@return cmp_git.CompletionItem[] +---@param handle_parsed? fun(parsed: any): TItem[]? +---@return cmp_git.CompletionItem[] items +---@return boolean ok false when the response is not JSON, or not a list after `handle_parsed`. function M.handle_response(response, handle_item, handle_parsed) - local items = {} - - local function process_data(ok, parsed) - if not ok then - log.warn("Failed to parse api result") - return - end - - if handle_parsed then - parsed = handle_parsed(parsed) - end + local ok, parsed = pcall(vim.json.decode, response) + if not ok then + log.warn("Failed to parse api result") + return {}, false + end - for _, item in ipairs(parsed) do - table.insert(items, handle_item(item)) - end + if handle_parsed then + parsed = handle_parsed(parsed) end - local ok, parsed = pcall(vim.json.decode, response) - process_data(ok, parsed) + -- An error object or `null` must not look like a list without items, because the caller caches an empty list + if not vim.islist(parsed) then + log.debug("The api result is not a list") + return {}, false + end - return items + local items = {} + for _, item in ipairs(parsed) do + table.insert(items, handle_item(item)) + end + return items, true end ---@generic TItem ---@param result string ---@param handle_item fun(item: TItem): cmp_git.CompletionItem ----@param handle_parsed? fun(parsed: any): TItem[] ----@return cmp_git.CompletionList +---@param handle_parsed? fun(parsed: any): TItem[]? +---@return cmp_git.CompletionList list +---@return boolean ok false when the result is not JSON, or not a list after `handle_parsed`. Then the list is empty. function M.completion_list(result, handle_item, handle_parsed) - return { - items = M.handle_response(result, handle_item, handle_parsed), - isIncomplete = false, - } + local items, ok = M.handle_response(result, handle_item, handle_parsed) + return { items = items, isIncomplete = false }, ok end return M diff --git a/lua/cmp_git/source.lua b/lua/cmp_git/source.lua index 8fd2f95..77a5fd8 100644 --- a/lua/cmp_git/source.lua +++ b/lua/cmp_git/source.lua @@ -2,7 +2,7 @@ local Core = require("cmp_git.core") ---The fields of nvim-cmp's `cmp.SourceCompletionApiParams` that cmp-git reads. ---@class cmp_git.CmpCompletionParams ----@field context { cursor_before_line: string, cursor: { row: integer, col: integer, character: integer } } +---@field context { cursor_before_line: string, cursor: { row: integer, col: integer, character: integer } } `col` is a 1-based byte column, and `character` counts UTF-16 code units. ---@field completion_context { triggerKind: integer, triggerCharacter?: string } local Source = {} @@ -27,24 +27,35 @@ function Source.new(overrides) end ---@param params cmp_git.CmpCompletionParams ----@param trigger_characters_str string +---@param trigger_characters string[] ---@return string? -local function trigger_character_from_params(params, trigger_characters_str) - if params.completion_context.triggerKind == 1 then - return string.match(params.context.cursor_before_line, "%s*([" .. trigger_characters_str .. "])%S*$") - elseif params.completion_context.triggerKind == 2 then +local function trigger_character_from_params(params, trigger_characters) + local trigger_kind = params.completion_context.triggerKind + -- nvim-cmp asks again with TriggerForIncompleteCompletions (3) when the user types after an incomplete list, such + -- as the first page of mentions. Like Invoked (1), it has no trigger character. + if trigger_kind == 1 or trigger_kind == 3 then + return Core.find_trigger_character(params.context.cursor_before_line, trigger_characters) + elseif trigger_kind == 2 then return params.completion_context.triggerCharacter end return nil end +---@param cursor? { row: integer, col: integer } +---@return { row: integer, character: integer }? +local function byte_cursor(cursor) + return cursor and { row = cursor.row, character = cursor.col - 1 } +end + +---@param params cmp_git.CmpCompletionParams +---@return cmp_git.CompletionContext function Source:_context_from_params(params) - local trigger_character = trigger_character_from_params(params, self.trigger_characters_str) + local trigger_character = trigger_character_from_params(params, self.core:get_trigger_characters()) return { trigger_character = trigger_character, - cursor = params.context.cursor, + cursor = byte_cursor(params.context.cursor), line = params.context.cursor_before_line, params = params, bufnr = vim.api.nvim_get_current_buf(), @@ -64,7 +75,7 @@ end function Source:_run_trigger_actions(trigger_character, callback, params, git_info) return self.core:_run_trigger_actions(trigger_character, callback, { trigger_character = trigger_character, - cursor = params and params.context and params.context.cursor, + cursor = params and params.context and byte_cursor(params.context.cursor), line = params and params.context and params.context.cursor_before_line, params = params, bufnr = vim.api.nvim_get_current_buf(), @@ -77,6 +88,13 @@ function Source:complete(params, callback) return self.core:complete(self:_context_from_params(params), callback) end +---nvim-cmp reads the text edit ranges as UTF-16 unless the source names another encoding. cmp-git finds the edit +---start in bytes. +---@return string +function Source:get_position_encoding_kind() + return "utf-8" +end + function Source:get_keyword_pattern() return self.core:get_keyword_pattern() end diff --git a/lua/cmp_git/sources/github.lua b/lua/cmp_git/sources/github.lua index 7145942..8f65917 100644 --- a/lua/cmp_git/sources/github.lua +++ b/lua/cmp_git/sources/github.lua @@ -1,22 +1,27 @@ local adapter = require("cmp_git.provider.adapters.github") +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.AsyncItemList ----@field in_progress boolean ----@field items cmp_git.CompletionItem[] - ---@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 +---@field owner? string +---@field repo? string +---@field waiters fun()[] + ---@class cmp_git.Source.GitHub ---@field config cmp_git.Config.GitHub ---@field cache cmp_git.Source.GitHub.Cache +---@field default_repos table By `host/owner/repo` of the remote. local GitHub = {} ---@param overrides cmp_git.Config.GitHub @@ -32,26 +37,13 @@ function GitHub.new(overrides) change_requests = change_requests_cache, pull_requests = change_requests_cache, } + self.default_repos = {} self.config = overrides or {} return self end ----@param git_info cmp_git.GitInfo -local function use_gh_default_repo_if_set(git_info) - local gh_default_repo = vim.fn.system({ "gh", "repo", "set-default", "--view" }) - if vim.v.shell_error ~= 0 then - return git_info - end - local owner, repo = string.match(vim.fn.trim(gh_default_repo), "^(.+)/(.+)$") - if owner ~= nil and repo ~= nil then - git_info.owner = owner - git_info.repo = repo - end - return git_info -end - ---@param git_info cmp_git.GitInfo function GitHub:is_valid_host(git_info) if @@ -65,15 +57,95 @@ function GitHub:is_valid_host(git_info) return true end +---Call `on_resolved` with the repository that `gh repo set-default` chose, or with `git_info` when none is set or gh +---is not installed. gh runs at most once for each repository, and without blocking: it takes about 60 ms. ---@param git_info cmp_git.GitInfo -function GitHub:_normalize_git_info(git_info) - return use_gh_default_repo_if_set(git_info) +---@param bufnr integer The buffer of the request. gh reads the default repository of its working directory. +---@param on_resolved fun(git_info: cmp_git.GitInfo) +function GitHub:_resolve_default_repo(git_info, bufnr, on_resolved) + local key = string.format("%s/%s/%s", git_info.host, git_info.owner, git_info.repo) + local lookup = self.default_repos[key] + + local function resolve() + ---@cast lookup -nil + if lookup.owner and lookup.repo then + on_resolved(vim.tbl_extend("force", git_info, { owner = lookup.owner, repo = lookup.repo })) + else + on_resolved(git_info) + end + end + + if lookup and lookup.done then + resolve() + return + end + if lookup then + table.insert(lookup.waiters, resolve) + return + end + + local spec = { exec = "gh", args = { "repo", "set-default", "--view" }, bufnr = bufnr } + local job = command.build(spec, function(result, success) + ---@cast lookup -nil + -- Without a default repository, gh prints nothing. Outside a repository, it fails. + if success then + lookup.owner, lookup.repo = string.match(vim.trim(result), "^([^/%s]+)/([^/%s]+)$") + end + lookup.done = true + local waiters = lookup.waiters + lookup.waiters = {} + for _, waiter in ipairs(waiters) do + waiter() + end + end) + if not job then + -- gh is optional + on_resolved(git_info) + return + end + + lookup = { done = false, waiters = { resolve } } + self.default_repos[key] = lookup + job:start() +end + +---Run `start` with the repository to complete from. On a cache miss, that is the repository that `gh repo set-default` +---chose, so that issues, pull requests, and mentions come from the same repository. An octo buffer names its +---repository, and a cache hit needs no repository. +---@param git_info cmp_git.GitInfo +---@param bufnr integer +---@param cached boolean +---@param start fun(git_info: cmp_git.GitInfo): table? Starts the request and gives its job, if any. +---@return table? job nil or a table with an optional `cancel` method. +function GitHub:_with_repo(git_info, bufnr, cached, start) + if cached or vim.bo[bufnr].filetype == "octo" then + return start(git_info) + end + + local state = { cancelled = false, active = nil } + self:_resolve_default_repo(git_info, bufnr, function(resolved) + if state.cancelled then + return + end + state.active = start(resolved) + end) + + return { + command = "github", + cancel = function() + state.cancelled = true + if state.active and state.active.cancel then + state.active:cancel() + end + end, + } end ---@param callback fun(list: cmp_git.CompletionList) ---@param git_info cmp_git.GitInfo ---@param trigger_char string -function GitHub:_issues_job(callback, git_info, trigger_char) +---@param bufnr integer +function GitHub:_issues_job(callback, git_info, trigger_char, bufnr) return issues.job({ adapter = adapter, cache = self.cache.issues, @@ -81,13 +153,15 @@ function GitHub:_issues_job(callback, git_info, trigger_char) 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 -function GitHub:_change_requests_job(callback, git_info, trigger_char) +---@param bufnr integer +function GitHub:_change_requests_job(callback, git_info, trigger_char, bufnr) return change_requests.job({ adapter = adapter, cache = self.cache.change_requests, @@ -95,121 +169,144 @@ function GitHub:_change_requests_job(callback, git_info, trigger_char) config = self.config.pull_requests, 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 -function GitHub:complete_issues(callback, git_info, trigger_char) +---@param bufnr? integer The buffer of the request, by default the current one. +function GitHub:complete_issues(callback, git_info, trigger_char, bufnr) if not self:is_valid_host(git_info) then return false end - git_info = self:_normalize_git_info(git_info) - local job = self:_issues_job(callback, git_info, trigger_char) - if job then - job:start() - end - return true, job + -- 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) end ---@param callback fun(list: cmp_git.CompletionList) ---@param git_info cmp_git.GitInfo ---@param trigger_char string -function GitHub:complete_change_requests(callback, git_info, trigger_char) +---@param bufnr? integer The buffer of the request, by default the current one. +function GitHub:complete_change_requests(callback, git_info, trigger_char, bufnr) if not self:is_valid_host(git_info) then return false end - git_info = self:_normalize_git_info(git_info) - local job = self:_change_requests_job(callback, git_info, trigger_char) - if job then - job:start() - end - return true, job + 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) end ---@param callback fun(list: cmp_git.CompletionList) ---@param git_info cmp_git.GitInfo ---@param trigger_char string -function GitHub:complete_issues_and_change_requests(callback, git_info, trigger_char) - if not self:is_valid_host(git_info) then - return false - end - - git_info = self:_normalize_git_info(git_info) - - local issue_items = {} - local state = { cancelled = false, active = nil } - local controller = { - command = "github_issues_and_change_requests", - cancel = function() - state.cancelled = true - if state.active and state.active.cancel then - state.active:cancel() +---@param bufnr integer +function GitHub:_issues_and_change_requests(callback, git_info, trigger_char, bufnr) + local results = {} + local pending = 2 + local cancelled = false + + -- Each capability calls back once: at once on a cache hit, and with an empty list on a failure. So a failure of + -- one request still gives the items of the other. + ---@param kind "issues" | "change_requests" + local function collect(kind) + return function(list) + if cancelled then + return end - end, - } - - -- The capability jobs fill the caches. On a cache hit, a capability calls the callback at once and gives no job, - -- so the pull requests are requested only after the issues are known. - local function complete_change_requests() - if state.cancelled then - return - end - - local change_requests_job = self:_change_requests_job(function(args) - if state.cancelled then + results[kind] = list.items + pending = pending - 1 + if pending > 0 then return end + -- A new list, so that the issue cache does not get the pull requests - local items = vim.list_extend(vim.list_extend({}, issue_items), args.items) + local items = vim.list_extend(vim.list_extend({}, results.issues), results.change_requests) log.fmt_debug("Got %d issues and prs", #items) callback({ items = items, isIncomplete = false }) - end, git_info, trigger_char) - - if change_requests_job then - state.active = change_requests_job - change_requests_job:start() end end - local issues_job = self:_issues_job(function(args) - if state.cancelled then - return - end - issue_items = args.items - end, git_info, trigger_char) - + 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) + -- Both commands run at once, so that a cold request waits for the slower one, not for both in turn if issues_job then - state.active = issues_job - issues_job:start(complete_change_requests) - else - complete_change_requests() + issues_job:start() + end + if change_requests_job then + change_requests_job:start() end - return true, controller + return { + command = "github_issues_and_change_requests", + cancel = function() + cancelled = true + for _, job in ipairs({ issues_job or false, change_requests_job or false }) do + if job and job.cancel then + job:cancel() + end + end + end, + } end ---@param callback fun(list: cmp_git.CompletionList) ---@param git_info cmp_git.GitInfo ---@param trigger_char string -function GitHub:complete_mentions(callback, git_info, trigger_char) +---@param bufnr? integer The buffer of the request, by default the current one. +function GitHub:complete_issues_and_change_requests(callback, git_info, trigger_char, bufnr) 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 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) +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 GitHub:complete_mentions(callback, git_info, trigger_char, bufnr) + if not self:is_valid_host(git_info) then + return false + end - return true, job + -- 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) 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 d275a48..a41bc32 100644 --- a/lua/cmp_git/sources/gitlab.lua +++ b/lua/cmp_git/sources/gitlab.lua @@ -49,7 +49,8 @@ end ---@param callback fun(list: cmp_git.CompletionList) ---@param git_info cmp_git.GitInfo ---@param trigger_char string -function GitLab:complete_issues(callback, git_info, trigger_char) +---@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 @@ -61,6 +62,7 @@ function GitLab:complete_issues(callback, git_info, trigger_char) config = self.config.issues, git_info = git_info, trigger_char = trigger_char, + bufnr = bufnr, }) return true, job @@ -69,7 +71,8 @@ end ---@param callback fun(list: cmp_git.CompletionList) ---@param git_info cmp_git.GitInfo ---@param trigger_char string -function GitLab:complete_mentions(callback, git_info, trigger_char) +---@param bufnr? integer The buffer of the request, by default the current one. +function GitLab:complete_mentions(callback, git_info, trigger_char, bufnr) if not self:is_valid_host(git_info) then return false end @@ -81,6 +84,7 @@ function GitLab:complete_mentions(callback, git_info, trigger_char) config = self.config.mentions, git_info = git_info, trigger_char = trigger_char, + bufnr = bufnr, }) return true, job @@ -89,7 +93,8 @@ 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) +---@param bufnr? integer The buffer of the request, by default the current one. +function GitLab:complete_change_requests(callback, git_info, trigger_char, bufnr) if not self:is_valid_host(git_info) then return false end @@ -101,6 +106,7 @@ function GitLab:complete_change_requests(callback, git_info, trigger_char) config = self.config.merge_requests, git_info = git_info, trigger_char = trigger_char, + bufnr = bufnr, }) return true, job diff --git a/lua/cmp_git/utils.lua b/lua/cmp_git/utils.lua index db75e29..bb905fd 100644 --- a/lua/cmp_git/utils.lua +++ b/lua/cmp_git/utils.lua @@ -1,9 +1,33 @@ local M = {} +local byte = string.byte + +-- The bytes that `%s` matches +local SPACE = { [9] = true, [10] = true, [11] = true, [12] = true, [13] = true, [32] = true } + +---The bounds of `s:sub(first, last)` without its leading and trailing whitespace, so that a parser can trim a field +---without a copy of it. It reads each byte at most once, unlike patterns such as `^%s*(.-)%s*$` and `^%s*(.*%S)`, +---which take quadratic time on long runs of whitespace. +---@param s string +---@param first integer +---@param last integer +---@return integer first +---@return integer last Less than `first` when the range has only whitespace. +function M.trim_range(s, first, last) + while first <= last and SPACE[byte(s, first)] do + first = first + 1 + end + while last >= first and SPACE[byte(s, last)] do + last = last - 1 + end + return first, last +end + ---@param s string ---@return string function M.trim(s) - return (s:gsub("^%s*(.-)%s*$", "%1")) + local first, last = M.trim_range(s, 1, #s) + return string.sub(s, first, last) end ---Split NUL-terminated records. A trailing record without a NUL is dropped. diff --git a/scripts/bench-compare.sh b/scripts/bench-compare.sh new file mode 100755 index 0000000..86093c1 --- /dev/null +++ b/scripts/bench-compare.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Compares the benchmarks of a git revision with the working tree. +# +# scripts/bench-compare.sh [revision] [compare options] +# The revision defaults to main. The other arguments go to benchmarks/compare.lua, for example +# `--time-threshold 0.5`. Both sides run the cases of the working tree, so that the report shows only the +# change in lua/. The script exits with 1 when the working tree regressed. +# +# Environment: +# NVIM The Neovim to run (default: nvim). +# BENCH_RUNS Runs of each side (default: 2). The sides alternate, and each side uses its best run. +# GITHUB_STEP_SUMMARY When it is set, the report is also appended there as Markdown. +set -euo pipefail + +rev=${1:-main} +shift || true +nvim=${NVIM:-nvim} +runs=${BENCH_RUNS:-2} +root=$(git rev-parse --show-toplevel) +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +base_rev=$(git -C "$root" rev-parse --short "$rev") +mkdir "$tmp/base" +git -C "$root" archive "$rev" lua | tar -x -C "$tmp/base" + +run() { + "$nvim" --headless --clean -l "$root/benchmarks/run.lua" --allow-errors "$@" >/dev/null +} + +base_files="" +head_files="" +for i in $(seq "$runs"); do + echo "Run $i of $runs: $rev" >&2 + run --root "$tmp/base" --revision "$base_rev" --json "$tmp/base-$i.json" + echo "Run $i of $runs: working tree" >&2 + run --root "$root" --json "$tmp/head-$i.json" + base_files="${base_files:+$base_files,}$tmp/base-$i.json" + head_files="${head_files:+$head_files,}$tmp/head-$i.json" +done + +args=("$base_files" "$head_files" "$@") +if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + args+=(--markdown "$GITHUB_STEP_SUMMARY") +fi +"$nvim" --headless --clean -l "$root/benchmarks/compare.lua" "${args[@]}" diff --git a/tests/provider/adapters/test_github.lua b/tests/provider/adapters/test_github.lua index f43a988..e2bfb65 100644 --- a/tests/provider/adapters/test_github.lua +++ b/tests/provider/adapters/test_github.lua @@ -114,15 +114,71 @@ T["enterprise_host"] = function() ) end +---The requests that run curl, so that a case can check each of them. +---@return table +local function all_requests() + return { + issues = adapter.issues_request(github, "#", github_config().issues), + change_requests = adapter.change_requests_request(github, "#", github_config().pull_requests), + preflight = adapter.mentions_preflight_request(github), + mentions = adapter.mentions_request(github, "@", github_config().mentions, { page = 1, page_size = 100 }), + } +end + T["token_header"] = function() local request = adapter.issues_request(github, "#", github_config().issues) eq(curl_header(request.commands[2], "Authorization"), nil, "no token, no header") + eq(request.commands[2].stdin, nil, "no token, no stdin") eq(request.commands[1].env.GITHUB_API_TOKEN, nil, "no token for gh") vim.env.GITHUB_API_TOKEN = "secret" - request = adapter.issues_request(github, "#", github_config().issues) - eq(curl_header(request.commands[2], "Authorization"), "token secret", "curl sends the token") - eq(request.commands[1].env.GITHUB_API_TOKEN, "secret", "gh gets the token") + for name, token_request in pairs(all_requests()) do + local curl = token_request.commands[2] + eq(curl.stdin, "Authorization: token secret\n", name .. ": curl reads the token header from stdin") + eq(curl_header(curl, "Authorization"), nil, name .. ": the token is not an argument") + eq(vim.tbl_contains(curl.args, "@-"), true, name .. ": curl reads headers from stdin") + eq(token_request.commands[1].env.GITHUB_API_TOKEN, "secret", name .. ": gh gets the token") + end +end + +T["curl_fails_on_http_errors"] = function() + for name, request in pairs(all_requests()) do + -- The preflight reads the status of the error body + eq(vim.tbl_contains(request.commands[2].args, "--fail"), name ~= "preflight", name .. ": --fail") + end +end + +T["error_and_null_responses_are_not_lists"] = function() + local requests = all_requests() + + eq(requests.mentions.handle_parsed(vim.NIL), vim.NIL, "a null mention response stays null") + eq(requests.preflight.handle_result("null"), { member_type = "contributors" }, "a null preflight falls back") + eq(requests.preflight.handle_result("[]"), { member_type = "collaborators" }, "a list is not an error") +end + +T["token_reaches_curl_on_stdin"] = function() + local bin = helpers.tempdir() + local file = assert(io.open(bin .. "/curl", "w")) + -- Prints the arguments on the first line, then stdin + file:write('#!/bin/sh\nprintf "%s\\n" "$*"\ncat\n') + file:close() + vim.uv.fs_chmod(bin .. "/curl", tonumber("755", 8)) + helpers.stub(vim.env, "PATH", bin .. ":" .. vim.env.PATH) + vim.env.GITHUB_API_TOKEN = "secret" + + local output + local curl = adapter.issues_request(github, "#", github_config().issues).commands[2] + local job = helpers.not_nil(command.build(curl, function(stdout) + output = stdout + end)) + job:start() + helpers.wait_for(function() + return output ~= nil + end, "the command completes") + + local lines = vim.split(output, "\n", { trimempty = true }) + eq(lines[1]:find("secret", 1, true), nil, "the arguments have no token") + eq(lines[2], "Authorization: token secret", "stdin has the token header") end T["unset_token_does_not_reach_gh"] = function() @@ -251,8 +307,8 @@ T["issues_request_drops_pull_requests"] = function() { { number = 1 }, { number = 3 } }, "the REST issues endpoint also returns pull requests" ) - eq(request.handle_parsed({ message = "Not Found" }), {}, "an error response gives no issues") - eq(request.handle_parsed(vim.NIL), {}, "a null response gives no issues") + eq(request.handle_parsed({ message = "Not Found" }), nil, "an error response is not a list") + eq(request.handle_parsed(vim.NIL), nil, "a null response is not a list") end T["handle_item_normalizes_body"] = function() @@ -268,4 +324,18 @@ T["handle_item_normalizes_body"] = function() eq(null_body.data.body, "", "a null body is empty") end +T["handle_item_fills_null_text"] = function() + local requests = all_requests() + for _, name in ipairs({ "issues", "change_requests" }) do + local item = requests[name].handle_item({ + number = 3, + title = vim.NIL, + body = vim.NIL, + updated_at = "2024-01-01T00:00:00Z", + }) + eq(item.label, "#3: ", name .. ": a null title is empty") + eq(item.documentation.value, "# \n\n", name .. ": the documentation has no vim.NIL text") + end +end + return T diff --git a/tests/provider/adapters/test_gitlab.lua b/tests/provider/adapters/test_gitlab.lua index 9b6c666..ff5658c 100644 --- a/tests/provider/adapters/test_gitlab.lua +++ b/tests/provider/adapters/test_gitlab.lua @@ -26,6 +26,8 @@ T["issues_request"] = function() eq(curl.exec, "curl", "curl command") eq(curl.args, { "-s", + "-S", + "--fail", "https://gitlab.com/api/v4/projects/group%2Fsubgroup%2Fproject/issues?per_page=100&state=opened", }, "curl arguments") end @@ -43,6 +45,8 @@ T["change_requests_request"] = function() eq(request.commands[1].env.NO_COLOR, 1, "glab output has no colors") eq(request.commands[2].args, { "-s", + "-S", + "--fail", "https://gitlab.example.com/api/v4/projects/group%2Fproject/merge_requests?per_page=3&state=merged", }, "curl arguments for a self-hosted host") end @@ -54,6 +58,8 @@ T["mentions_request"] = function() eq(request.commands[1].env.NO_COLOR, 1, "glab output has no colors") eq(request.commands[2].args, { "-s", + "-S", + "--fail", "https://gitlab.com/api/v4/projects/group%2Fsubgroup%2Fproject/users?per_page=20", }, "curl arguments") eq(adapter.mentions_has_more(), false, "one page of mentions") @@ -65,8 +71,9 @@ T["issue_state_and_limit"] = function() adapter.issues_request(gitlab, "#", gitlab_config({ issues = { state = "closed", limit = 5 } }).issues) eq(request.commands[1].args[2], "/projects/group%2Fsubgroup%2Fproject/issues?per_page=5&state=closed", "glab") + local curl_args = request.commands[2].args eq( - request.commands[2].args[2], + curl_args[#curl_args], "https://gitlab.com/api/v4/projects/group%2Fsubgroup%2Fproject/issues?per_page=5&state=closed", "curl" ) @@ -75,14 +82,24 @@ end T["token_header"] = function() local request = adapter.issues_request(gitlab, "#", gitlab_config().issues) eq(request.commands[1].env.GITLAB_TOKEN, nil, "no token for glab") - eq(#request.commands[2].args, 2, "no token, no curl header") + eq(vim.tbl_contains(request.commands[2].args, "-H"), false, "no token, no curl header") + eq(request.commands[2].stdin, nil, "no token, no stdin") vim.env.GITLAB_TOKEN = "secret" - request = adapter.issues_request(gitlab, "#", gitlab_config().issues) - - eq(request.commands[1].env.GITLAB_TOKEN, "secret", "glab gets the token") - eq(request.commands[2].args[3], "-H", "curl header flag") - eq(request.commands[2].args[4], "Authorization: Bearer secret", "curl sends the token") + local requests = { + issues = adapter.issues_request(gitlab, "#", gitlab_config().issues), + merge_requests = adapter.change_requests_request(gitlab, "!", gitlab_config().merge_requests), + mentions = adapter.mentions_request(gitlab, "@", gitlab_config().mentions), + } + for name, token_request in pairs(requests) do + local curl = token_request.commands[2] + eq(token_request.commands[1].env.GITLAB_TOKEN, "secret", name .. ": glab gets the token") + eq(curl.stdin, "Authorization: Bearer secret\n", name .. ": curl reads the token header from stdin") + eq(vim.tbl_contains(curl.args, "@-"), true, name .. ": curl reads headers from stdin") + for _, arg in ipairs(curl.args) do + eq(arg:find("secret", 1, true), nil, name .. ": the token is not an argument") + end + end end T["unset_token_does_not_reach_glab"] = function() @@ -118,4 +135,32 @@ T["handle_item_fills_null_description"] = function() eq(item.data.description, "", "a null description is empty") end +T["handle_item_fills_null_text"] = function() + local cases = { + issue = adapter.issues_request(gitlab, "#", gitlab_config().issues).handle_item({ + iid = 3, + title = vim.NIL, + description = vim.NIL, + updated_at = "2024-01-01T00:00:00.000Z", + }), + merge_request = adapter.change_requests_request(gitlab, "!", gitlab_config().merge_requests).handle_item({ + iid = 4, + title = vim.NIL, + description = vim.NIL, + updated_at = "2024-01-01T00:00:00.000Z", + }), + mention = adapter.mentions_request(gitlab, "@", gitlab_config().mentions).handle_item({ + username = "ada", + name = vim.NIL, + }), + } + + for name, item in pairs(cases) do + for _, text in ipairs({ item.label, item.filterText, item.insertText, item.documentation.value }) do + eq(text:find("vim.NIL", 1, true), nil, name .. ": no vim.NIL text in " .. vim.inspect(text)) + end + end + eq(cases.merge_request.documentation.value, "# \n\n", "a merge request with a null title and description") +end + return T diff --git a/tests/provider/capabilities/test_change_requests.lua b/tests/provider/capabilities/test_change_requests.lua index 89cf69c..7b640f3 100644 --- a/tests/provider/capabilities/test_change_requests.lua +++ b/tests/provider/capabilities/test_change_requests.lua @@ -1,6 +1,7 @@ local helpers = require("tests.helpers") local change_request_capability = require("cmp_git.provider.capabilities.change_requests") +local eq = helpers.eq local neq = helpers.neq local T = helpers.new_set() @@ -9,7 +10,7 @@ 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 }) + callback({ items = { handle_item({ name = "pr" }) }, isIncomplete = false }, true) end) end, } @@ -38,4 +39,44 @@ T["change_request_capability_returns_job"] = function() neq(job, nil, "change request capability returns started job") end +T["failure_gives_an_empty_list_and_is_not_cached"] = function() + local runs = 0 + local cache = {} + local callback, lists = helpers.recorder() + local args = { + adapter = { + change_requests_request = function() + return { commands = {}, handle_item = function() end } + end, + }, + cache = cache, + callback = callback, + config = {}, + git_info = {}, + trigger_char = "#", + runner = { + build_fallback_list = function(_commands, list_callback) + if runs == 2 then + return nil + end + return helpers.fake_job(function() + runs = runs + 1 + list_callback({ items = {}, isIncomplete = false }, false) + end) + end, + }, + bufnr = 46, + } + + change_request_capability.complete(args) + change_request_capability.complete(args) + -- The third request has no runnable command + change_request_capability.complete(args) + + 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(runs, 2, "the next request tries again") +end + return T diff --git a/tests/provider/capabilities/test_commits.lua b/tests/provider/capabilities/test_commits.lua index 1fb9777..96a255d 100644 --- a/tests/provider/capabilities/test_commits.lua +++ b/tests/provider/capabilities/test_commits.lua @@ -73,6 +73,109 @@ T["commit_capability_parse_marker_collision_and_malformed_records"] = function() eq(commits[1].description, "body with ###CMP_GIT_END### marker", "commit parser allows old entry marker text") end +-- The parser before it read the fields in place. The test below checks that the new parser gives the same output. +local function old_trim(s) + return (s:gsub("^%s*(.-)%s*$", "%1")) +end + +local function old_split_nul(input) + local fields = {} + local start = 1 + while true do + local pos = string.find(input, "\0", start, true) + if not pos then + break + end + table.insert(fields, string.sub(input, start, pos - 1)) + start = pos + 1 + end + return fields +end + +local function old_parse(result, config) + local fields = old_split_nul(result) + local commits = {} + + for i = 1, #fields, 6 do + if fields[i + 5] then + local timestamp = tonumber(old_trim(fields[i + 5])) or 0 + table.insert(commits, { + sha = old_trim(fields[i]):sub(1, config.sha_length), + title = old_trim(fields[i + 1]), + description = old_trim(fields[i + 2]), + author_name = fields[i + 3] or "", + author_mail = fields[i + 4] or "", + commit_timestamp = timestamp, + diff = os.difftime(os.time(), timestamp), + }) + end + end + + return commits +end + +---A deterministic random generator, so that a failure can be reproduced. +---@param seed integer +---@return fun(n: integer): integer random A number from 1 to n. +local function random_generator(seed) + return function(n) + seed = seed * 16807 % 2147483647 + return seed % n + 1 + end +end + +T["commit_capability_parse_matches_the_old_parser"] = function() + helpers.stub(os, "time", function() + return 1700000000 + end) + + local random = random_generator(42) + local function pick(list) + return list[random(#list)] + end + local fragments = { + "", + " ", + "\t", + "\r\n", + "\n", + "fix: subject", + " padded subject ", + "body line one\nbody line two\n", + "body\r\n\r\nCo-authored-by: Ada \r\n", + "Ünïcödé", + "\031", + "a" .. string.rep(" ", 50) .. "b", + } + local function text() + local parts = {} + for i = 1, random(3) - 1 do + parts[i] = pick(fragments) + end + return table.concat(parts) + end + local shas = { "", "abcdef1234567890abcdef1234567890abcdef12", " abc ", "\nabcdef1234567890", "a b c d e f g h" } + local timestamps = { "1700000000", " 1700000000 ", "\n1699990000\r", "", "abc", "1e3", "-5" } + + for _ = 1, 300 do + local commits = {} + for i = 1, random(5) - 1 do + commits[i] = table.concat({ pick(shas), text(), text(), text(), text(), pick(timestamps), "" }, "\0") + end + -- `--pretty=format:` puts a newline between commits. The last commit can lack fields. + local output = table.concat(commits, pick({ "\n", "" })) .. pick({ "", "dangling\0", "a\0b\0c\0d\0e" }) + + for _, sha_length in ipairs({ 7, 0, 40, 100 }) do + local config = { sha_length = sha_length } + eq( + commit_capability.parse(output, config), + old_parse(output, config), + string.format("parse of %s with sha_length %d", vim.inspect(output), sha_length) + ) + end + end +end + T["commit_capability_maps_items_without_cmp_params"] = function() local items = commit_capability.items({ { @@ -110,6 +213,7 @@ T["commit_capability_uses_parsed_cache_and_runner"] = function() "--pretty=format:%H%x00%s%x00%b%x00%aN%x00%aE%x00%cd%x00", "commit capability command format" ) + eq(spec.bufnr, 44, "commit capability command runs in the repository of the request buffer") return helpers.fake_job(function() callback(raw, true) end) @@ -137,4 +241,44 @@ T["commit_capability_uses_parsed_cache_and_runner"] = function() eq(last_list.items[1].label, ":abcdef1: cache me", "commit capability maps cached commits") end +T["commit_capability_answers_failures_without_caching"] = function() + local builds = 0 + local failing_runner = { + build = function(_spec, callback) + builds = builds + 1 + return helpers.fake_job(function() + -- As in a repository without commits + callback("", false) + end) + end, + } + local callback, lists = helpers.recorder() + local args = { + cache = {}, + callback = callback, + config = commit_test_config(), + trigger_char = ":", + runner = failing_runner, + bufnr = 45, + } + + commit_capability.complete(args) + eq(lists, { { items = {}, isIncomplete = false } }, "failed log gives one final empty list") + eq(args.cache[45], nil, "failed log is not cached") + commit_capability.complete(args) + eq(builds, 2, "the next request runs git log again") + eq(#lists, 2, "the next request calls back again") + + callback, lists = helpers.recorder() + args.callback = callback + -- As when git is not installed + args.runner = { + build = function() + return nil + end, + } + eq(commit_capability.complete(args), nil, "no job without git") + eq(lists, { { items = {}, isIncomplete = false } }, "no git gives one final empty list") +end + return T diff --git a/tests/provider/capabilities/test_issues.lua b/tests/provider/capabilities/test_issues.lua index e740e35..41d63bc 100644 --- a/tests/provider/capabilities/test_issues.lua +++ b/tests/provider/capabilities/test_issues.lua @@ -27,7 +27,7 @@ T["issue_capability_uses_cache_and_runner"] = function() runner_count = runner_count + 1 eq(commands[1].exec, "fake", "issue capability forwards commands") return helpers.fake_job(function() - callback({ items = { handle_item({ name = "one" }) }, isIncomplete = false }) + callback({ items = { handle_item({ name = "one" }) }, isIncomplete = false }, true) end) end, } @@ -56,4 +56,59 @@ T["issue_capability_uses_cache_and_runner"] = function() eq(cached_job, nil, "issue capability cache hit returns no job") 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 { + 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() + local runs = 0 + local args, lists = failing_args({ + build_fallback_list = function(_commands, callback) + return helpers.fake_job(function() + runs = runs + 1 + callback({ items = {}, isIncomplete = false }, false) + end) + end, + }) + + issue_capability.complete(args) + 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(runs, 2, "the next request tries again") +end + +T["no_runnable_command_gives_an_empty_list"] = function() + local args, lists = failing_args({ + build_fallback_list = function() + return nil + end, + }) + + eq(issue_capability.complete(args), nil, "no job") + eq(lists, { EMPTY }, "one empty list") + eq(args.cache[42], nil, "nothing is cached") +end + return T diff --git a/tests/provider/capabilities/test_mentions.lua b/tests/provider/capabilities/test_mentions.lua index 5545fdc..f953cd8 100644 --- a/tests/provider/capabilities/test_mentions.lua +++ b/tests/provider/capabilities/test_mentions.lua @@ -18,12 +18,13 @@ T["mention_capability_preflight_and_pagination"] = function() local page = commands[1].page table.insert(request_pages, page) return helpers.fake_job(function() - local raw_items = page == 1 and { { name = "one" } } or {} + -- A full first page, so that the second page is requested + local count = page == 1 and 100 or 1 local items = {} - for _, item in ipairs(raw_items) do - table.insert(items, handle_item(item)) + for i = 1, count do + table.insert(items, handle_item({ name = page .. "-" .. i })) end - callback({ items = items, isIncomplete = false }) + callback({ items = items, isIncomplete = false }, true) end) end, } @@ -53,7 +54,7 @@ T["mention_capability_preflight_and_pagination"] = function() adapter = adapter, cache = {}, callback = callback, - config = { limit = 2 }, + config = { limit = 200 }, git_info = {}, trigger_char = "@", runner = runner, @@ -64,6 +65,253 @@ T["mention_capability_preflight_and_pagination"] = function() eq(#callbacks, 2, "mention capability returns partial and final callbacks") eq(callbacks[1].isIncomplete, true, "first mention callback is incomplete") eq(callbacks[2].isIncomplete, false, "final mention callback is complete") + eq(#callbacks[2].items, 101, "the final callback has the users of both pages") +end + +---@class tests.MentionJob: cmp_git.SystemJob +---@field page integer +---@field page_size integer +---@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. +---@return table runner +---@return table adapter +---@return tests.MentionJob[] jobs The started jobs, in order. +local function manual_provider() + local jobs = {} + local runner = { + build_fallback_list = function(commands, callback, handle_item) + local job = { command = "fake", page = commands[1].page, page_size = commands[1].page_size } + function job.start() + table.insert(jobs, job) + end + function job.cancel() + job.cancelled = true + end + function job.respond(users) + if not users then + callback({ items = {}, isIncomplete = false }, false) + return + end + 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, + } + end, + } + return runner, adapter, jobs +end + +---@param count integer +---@param first? integer +---@return string[] +local function users(count, first) + local result = {} + for i = first or 1, (first or 1) + count - 1 do + table.insert(result, "user" .. i) + end + return result +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 + +---@param runner table +---@param adapter table +---@param cache table +---@param limit integer +---@return cmp_git.CompletionList[] lists +---@return { cancel: fun() }? controller +local function request(runner, adapter, cache, limit) + local callback, lists = helpers.recorder() + local controller = mention_capability.complete({ + adapter = adapter, + cache = cache, + callback = callback, + config = { limit = limit }, + git_info = {}, + trigger_char = "@", + runner = runner, + bufnr = 7, + }) + return lists, controller +end + +T["fewer_users_than_the_limit_need_one_page"] = function() + local runner, adapter, jobs = manual_provider() + local lists = request(runner, adapter, {}, 100) + + jobs[1].respond(users(30)) + + eq(#jobs, 1, "a page that is not full is the last one") + eq(#lists, 1, "one list") + eq(lists[1].isIncomplete, false, "the list is final") + eq(#lists[1].items, 30, "all users") +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) + + jobs[1].respond(users(100)) + jobs[2].respond(users(100, 101)) + + eq({ jobs[1].page, jobs[1].page_size }, { 1, 100 }, "the first page") + eq({ jobs[2].page, jobs[2].page_size }, { 2, 100 }, "the second page has the same size") + 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") +end + +T["a_cancel_removes_the_entry"] = function() + local runner, adapter, jobs = manual_provider() + local cache = {} + local lists, controller = request(runner, adapter, cache, 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(lists, {}, "a cancelled request gets no list") + + local next_lists = request(runner, adapter, cache, 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") +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) + + jobs[1].respond(nil) + + eq(lists, { { items = {}, isIncomplete = false } }, "one empty list") + eq(cache[7], nil, "a failure is not cached") + + request(runner, adapter, cache, 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) + + jobs[1].respond(users(100)) + jobs[2].respond(nil) + + 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") +end + +T["no_runnable_command_gives_an_empty_list"] = function() + local cache = {} + local runner = { + build_fallback = function() + return nil + end, + build_fallback_list = function() + return nil + end, + } + local adapter = { + mentions_preflight_request = function() + return { commands = {} } + end, + mentions_request = function() + return { commands = {}, handle_item = function() end } + end, + } + + local lists = request(runner, adapter, cache, 100) + + eq(lists, { { items = {}, isIncomplete = false } }, "one empty list") + eq(cache[7], nil, "nothing is cached") +end + +T["a_failed_preflight_still_fetches"] = function() + local runner, adapter, jobs = manual_provider() + local preflight_results = {} + runner.build_fallback = function(_commands, callback) + return helpers.fake_job(function() + callback("", false) + end) + end + adapter.mentions_preflight_request = function() + return { + commands = {}, + handle_result = function(result, success) + table.insert(preflight_results, { result, success }) + return { member_type = "contributors" } + end, + } + end + + local lists = request(runner, adapter, {}, 100) + jobs[1].respond(users(2)) + + eq(preflight_results, { { "", false } }, "handle_result gets the failure") + eq(#lists, 1, "one list") + eq(#lists[1].items, 2, "the users") +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) + 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) + eq(#third, 1, "a request during the fetch gets the users so far") + eq(third[1].isIncomplete, true, "and more come") + + -- blink.cmp cancels the request of a list that it no longer shows + helpers.not_nil(first_controller):cancel() + eq(jobs[2].cancelled, nil, "the fetch goes on for the other requests") + + jobs[2].respond(users(5, 101)) + + for name, lists in pairs({ second = second, third = third }) do + local final = lists[#lists] + eq(final.isIncomplete, false, name .. " gets a final list") + 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") + + local cached = request(runner, adapter, cache, 300) + eq(#jobs, 2, "a cache hit does not fetch") + eq(cached[1].isIncomplete, false, "a cache hit is final") end return T diff --git a/tests/provider/capabilities/test_trailers.lua b/tests/provider/capabilities/test_trailers.lua index b43a850..b3023b7 100644 --- a/tests/provider/capabilities/test_trailers.lua +++ b/tests/provider/capabilities/test_trailers.lua @@ -92,6 +92,302 @@ T["trailer_keys_parse_and_sort"] = function() eq(items[1].insertText, "Signed-off-by: ", "trailer key insert text") end +-- The parsers before they read the fields in place. The tests below check that the new parsers give the same output. +local old = {} + +function old.trim(s) + return (s:gsub("^%s*(.-)%s*$", "%1")) +end + +function old.split_nul(input) + local fields = {} + local start = 1 + while true do + local pos = string.find(input, "\0", start, true) + if not pos then + break + end + table.insert(fields, string.sub(input, start, pos - 1)) + start = pos + 1 + end + return fields +end + +function old.parse_ident(value) + local name, email = string.match(value, "^%s*(.-)%s*<([^>]+)>") + if not name or name == "" or old.trim(email) == "" then + return nil, nil + end + return name, old.trim(email) +end + +function old.keys_parse(result, keys_config) + local keys = {} + local by_key = {} + + for index, key in ipairs(keys_config.builtin or {}) do + if not by_key[key:lower()] then + local trailer = { key = key, count = 0, builtin_index = index } + by_key[key:lower()] = trailer + table.insert(keys, trailer) + end + end + + for _, record in ipairs(old.split_nul(result)) do + for _, key in ipairs(vim.split(record, "\031", { plain = true })) do + key = old.trim(key) + if key:match("^[%w-]+$") then + local trailer = by_key[key:lower()] + if not trailer then + trailer = { key = key, count = 0 } + by_key[key:lower()] = trailer + table.insert(keys, trailer) + end + trailer.count = trailer.count + 1 + end + end + end + + return keys +end + +function old.identities_parse(log_result, ident_result) + local now = os.time() + local identities = {} + local by_email = {} + + local function add(name, email, timestamp, source, is_self) + name = old.trim(name) + email = old.trim(email) + if name == "" or email == "" then + return + end + + local existing = by_email[email:lower()] + if existing then + existing.is_self = existing.is_self or is_self + return + end + + local entry = { + name = name, + email = email, + timestamp = timestamp, + diff = timestamp and os.difftime(now, timestamp) or 0, + source = source, + is_self = is_self, + } + by_email[email:lower()] = entry + table.insert(identities, entry) + end + + for _, record in ipairs(old.split_nul(log_result)) do + local fields = vim.split(record, "\031", { plain = true }) + if #fields >= 3 then + local timestamp = tonumber(old.trim(fields[1])) + add(fields[2], fields[3], timestamp, "author", false) + + for i = 4, #fields do + local key, value = string.match(fields[i], "^%s*([%w-]+):%s*(.-)%s*$") + local name, email = old.parse_ident(value or "") + if name and email then + add(name, email, timestamp, key, false) + end + end + end + end + + local name, email = old.parse_ident(ident_result) + if name and email then + add(name, email, nil, "self", true) + end + + return identities +end + +function old.values_parse(result) + local now = os.time() + local by_key = {} + local by_value = {} + + for _, record in ipairs(old.split_nul(result)) do + local fields = vim.split(record, "\031", { plain = true }) + local timestamp = tonumber(old.trim(fields[1] or "")) + + for i = 2, #fields do + local key, value = string.match(fields[i], "^%s*([%w-]+):%s*(.-)%s*$") + if key and value ~= "" then + local id = key:lower() + by_key[id] = by_key[id] or {} + by_value[id] = by_value[id] or {} + + local entry = by_value[id][value] + if not entry then + entry = { + key = key, + value = value, + count = 0, + timestamp = timestamp, + diff = timestamp and os.difftime(now, timestamp) or 0, + } + by_value[id][value] = entry + table.insert(by_key[id], entry) + end + entry.count = entry.count + 1 + end + end + end + + return by_key +end + +---Random `git log` outputs in the formats of the trailer requests, with the edge cases that the parsers handle. +---@param seed integer +---@return fun(): string log An output of `identities.request` or `values.request`. +---@return fun(): string keys_log An output of `keys.request`. +---@return fun(): string ident An output of `identities.ident_request`. +local function trailer_outputs(seed) + -- A deterministic generator, so that a failure can be reproduced + local function random(n) + seed = seed * 16807 % 2147483647 + return seed % n + 1 + end + local function pick(list) + return list[random(#list)] + end + + local times = { "1700000000", "\n1700000000", " 1699990000 ", "", "abc", "\n" } + local names = { "Ada Lovelace", " Ada Lovelace ", "", "Grace Hopper", "Me", "\t" } + local emails = { "ada@example.com", "ADA@Example.com", " grace@example.com ", "", "me@example.com", "x" } + local keys = { "Co-authored-by", "co-AUTHORED-by", "Signed-off-by", "Fixes", "Assisted-by", "bad key", " Refs" } + local separators = { ":", ": ", ": ", " :", ":\t" } + local values = { + "", + " ", + "Ada Lovelace ", + "Ada ", + "Grace Hopper ", + " Grace Hopper < grace@example.com > ", + "", + "No Email", + "Ada <>", + "Ada < >", + "A <> B ", + "a > b ", + "x ", + } + + local function trailer() + return pick(keys) .. pick(separators) .. pick(values) + end + + local function output(fields) + local records = {} + for i = 1, random(6) - 1 do + -- `--format=` ends each record with a newline + records[i] = table.concat(fields(), "\031") .. "\0" .. pick({ "\n", "" }) + end + -- A trailing record without a NUL is dropped + return table.concat(records) .. pick({ "", "", "1700000000\031Ada\031ada@example.com" }) + end + + local function log() + return output(function() + local fields = { pick(times) } + for i = 2, random(6) do + fields[i] = i == 2 and random(2) == 1 and pick(names) + or i == 3 and random(2) == 1 and pick(emails) + or trailer() + end + return fields + end) + end + + local key_names = { + "Signed-off-by", + " co-authored-by ", + "Change-Id", + "%(trailers:keyonly,separator=%x1f)", + "", + "\n", + "bad key", + "Fixes\r", + "FIXES", + } + local function keys_log() + return output(function() + local fields = {} + for i = 1, random(4) do + fields[i] = pick(key_names) + end + return fields + end) + end + + local idents = { + "", + "Me 1 +0000\n", + "Ada Lovelace 1 +0000", + " Spaced Name < spaced@example.com > 1 +0000\n", + "garbage", + } + local function ident() + return pick(idents) + end + + return log, keys_log, ident +end + +T["trailer_parsers_match_the_old_parsers"] = function() + helpers.stub(os, "time", function() + return 1700000000 + end) + + local log, keys_log, ident = trailer_outputs(7) + local keys_config = { builtin = { "Co-authored-by", "Signed-off-by", "fixes", "Fixes" } } + for _ = 1, 300 do + local output = log() + local ident_output = ident() + local reason = vim.inspect(output) .. " and " .. vim.inspect(ident_output) + eq( + trailer_capability.identities.parse(output, ident_output), + old.identities_parse(output, ident_output), + "identities of " .. reason + ) + eq(trailer_capability.values.parse(output), old.values_parse(output), "values of " .. vim.inspect(output)) + + output = keys_log() + eq( + trailer_capability.keys.parse(output, keys_config), + old.keys_parse(output, keys_config), + "keys of " .. vim.inspect(output) + ) + end +end + +T["trailer_parsers_take_linear_time"] = function() + -- The old patterns took about 3 s for each of these values + local run = string.rep(" ", 32000) + local start = os.clock() + + local by_key = trailer_capability.values.parse("1\031Refs: a" .. run .. "b" .. run .. "\0") + eq(by_key["refs"][1].value, "a" .. run .. "b", "value with a long run of spaces") + + local identities = trailer_capability.identities.parse( + "1\031Ada\031ada@example.com\031Co-authored-by: Grace" .. run .. "Hopper" .. run .. "\0", + "" + ) + eq(identities[2].name, "Grace" .. run .. "Hopper", "identity name with a long run of spaces") + ok(os.clock() - start < 0.25, "trailers with long runs of spaces parse fast") +end + T["trailer_identities_request"] = function() local spec = trailer_capability.identities.request({ keys = { "Co-authored-by", "bad key", "Signed-off-by" }, @@ -145,7 +441,8 @@ T["trailer_capabilities_use_cache_and_runner"] = function() if spec.args[1] == "var" then callback("Me 1 +0000", true) else - callback("fatal: no commits", false) + -- A history without trailers + callback("", true) end end) end, @@ -165,7 +462,7 @@ T["trailer_capabilities_use_cache_and_runner"] = function() eq(table.concat(builds, ","), "log", "trailer keys run once") eq(#key_lists, 2, "trailer keys callback on miss and hit") - eq(#key_lists[1].items, #trailers_config.keys.builtin, "failed log still gives the built-in keys") + eq(#key_lists[1].items, #trailers_config.keys.builtin, "history without trailers gives the built-in keys") builds = {} local identities_callback, identities_lists = helpers.recorder() @@ -181,36 +478,156 @@ T["trailer_capabilities_use_cache_and_runner"] = function() eq(table.concat(builds, ","), "log,var", "trailer identities run log and var once") eq(#identities_lists, 2, "trailer identities callback on miss and hit") - eq(identities_lists[1].items[1].label, "Me ", "failed log still gives your identity") + eq(identities_lists[1].items[1].label, "Me ", "history without authors gives your identity") end -T["trailer_identities_job_cancel"] = function() - local pending - local builds = 0 +---A runner whose commands give the outputs of a history with one commit by Ada, or fail. +---@param fails { log?: boolean, var?: boolean, build?: boolean } The commands that fail. `build` gives no job, as when +---git is not installed. +---@return table runner +---@return string[] builds The commands of the jobs that the runner made. +local function failing_runner(fails) + local builds = {} local runner = { - build = function(_spec, callback) - builds = builds + 1 + build = function(spec, callback) + if fails.build then + return nil + end + + local name = spec.args[1] + table.insert(builds, name) return helpers.fake_job(function() - pending = callback + if fails[name] then + callback("", false) + elseif name == "var" then + callback("Me 1 +0000\n", true) + else + callback("1700000000\031Ada\031ada@example.com\031Assisted-by: Codex\0\n", true) + end end) end, } - local called = false - local job = trailer_capability.identities.complete({ - cache = {}, - callback = function() - called = true + return runner, builds +end + +T["trailer_capabilities_answer_failures_without_caching"] = function() + local trailers_config = config.normalize({}).git.trailers + + ---Runs a request two times, and checks that each request ends with one final list and that nothing is cached. + ---@return string[] labels The labels of the first list. + local function complete(kind, fails, expected_builds) + local runner, builds = failing_runner(fails) + local callback, lists = helpers.recorder() + local args = { + cache = {}, + callback = callback, + config = trailers_config[kind], + key = "Assisted-by", + runner = runner, + bufnr = 54, + } + local reason = string.format("%s with %s", kind, vim.inspect(fails)) + + trailer_capability[kind].complete(args) + eq(#lists, 1, reason .. " calls back once") + eq(lists[1].isIncomplete, false, reason .. " gives a final list") + eq(args.cache[54], nil, reason .. " is not cached") + + trailer_capability[kind].complete(args) + eq(#lists, 2, reason .. " calls back again") + eq(table.concat(builds, ","), expected_builds, reason .. " runs again") + + local labels = {} + for _, item in ipairs(lists[1].items) do + table.insert(labels, item.label) + end + return labels + end + + eq( + #complete("keys", { log = true }, "log,log"), + #trailers_config.keys.builtin, + "failed log gives the built-in keys" + ) + eq(#complete("keys", { build = true }, ""), #trailers_config.keys.builtin, "no git gives the built-in keys") + + eq(complete("identities", { log = true }, "log,var,log,var"), { "Me " }, "failed log") + eq(complete("identities", { var = true }, "log,var,log,var"), { "Ada " }, "failed git var") + eq(complete("identities", { log = true, var = true }, "log,var,log,var"), {}, "failed log and git var") + eq(complete("identities", { build = true }, ""), {}, "no git gives no identities") + + eq(complete("values", { log = true }, "log,log"), {}, "failed log gives no values") + eq(complete("values", { build = true }, ""), {}, "no git gives no values") +end + +---A runner whose jobs answer when the test calls their callbacks. +---@return table runner +---@return { started: string[], cancelled: string[], answer: table } jobs +local function manual_runner() + local jobs = { started = {}, cancelled = {}, answer = {} } + local runner = { + build = function(spec, callback) + local name = spec.args[1] + return { + command = spec.exec, + start = function() + table.insert(jobs.started, name) + jobs.answer[name] = callback + end, + cancel = function() + table.insert(jobs.cancelled, name) + end, + } end, + } + return runner, jobs +end + +T["trailer_identities_run_log_and_var_at_once"] = function() + local runner, jobs = manual_runner() + local callback, lists = helpers.recorder() + local args = { + cache = {}, + callback = callback, + config = config.normalize({}).git.trailers.identities, + runner = runner, + bufnr = 55, + } + trailer_capability.identities.complete(args) + + eq(jobs.started, { "log", "var" }, "git log and git var start before either answers") + jobs.answer.var("Me 1 +0000\n", true) + eq(#lists, 0, "the list waits for git log") + jobs.answer.log("1700000000\031Ada\031ada@example.com\0\n", true) + + eq(#lists, 1, "one list when both answer") + local labels = {} + for _, item in ipairs(lists[1].items) do + table.insert(labels, item.label) + end + eq(labels, { "Ada ", "Me " }, "the list has the log and your identity") + ok(args.cache[55] ~= nil, "the result of both commands is cached") +end + +T["trailer_identities_job_cancel"] = function() + local runner, jobs = manual_runner() + local callback, lists = helpers.recorder() + local args = { + cache = {}, + callback = callback, config = config.normalize({}).git.trailers.identities, runner = runner, bufnr = 52, - }) + } + local job = helpers.not_nil(trailer_capability.identities.complete(args)) + jobs.answer.var("Me 1 +0000\n", true) job:cancel() - pending("", true) + jobs.answer.log("", true) - eq(builds, 1, "cancelled trailer identities job does not start git var") - eq(called, false, "cancelled trailer identities job does not call back") + eq(jobs.cancelled, { "log", "var" }, "cancel stops both commands") + eq(#lists, 0, "cancelled trailer identities job does not call back") + eq(args.cache[52], nil, "cancelled trailer identities job does not cache") end T["trailer_capabilities_with_git"] = function() @@ -318,7 +735,8 @@ T["trailer_values_cache_and_fall_through"] = function() build = function(_spec, callback) builds = builds + 1 return helpers.fake_job(function() - callback("fatal: no commits", false) + -- A history without trailers + callback("1700000000\0\n", true) end) end, } @@ -338,7 +756,7 @@ T["trailer_values_cache_and_fall_through"] = function() eq(builds, 1, "trailer values run once") eq(#lists, 2, "trailer values callback on miss and hit") - eq(#lists[1].items, 0, "failed log gives no trailer values") + eq(#lists[1].items, 0, "history without trailers gives no trailer values") ok( not trailer_capability.values.may_have(args.cache, { bufnr = 53, line = "Assisted-by: C" }), "cached history without values for the key" @@ -352,4 +770,29 @@ T["trailer_values_cache_and_fall_through"] = function() eq(#core:_route(context), 0, "body line without history values falls through") end +T["trailer_commands_run_in_the_repository_of_the_request_buffer"] = function() + local seen = {} + local runner = { + build = function(spec, callback) + table.insert(seen, { spec.args[1], spec.bufnr }) + return helpers.fake_job(function() + callback("", true) + end) + end, + } + local trailers = config.normalize({}).git.trailers + for _, kind in ipairs({ "keys", "identities", "values" }) do + trailer_capability[kind].complete({ + cache = {}, + callback = function() end, + config = trailers[kind], + runner = runner, + bufnr = 61, + key = "Assisted-by", + }) + end + + eq(seen, { { "log", 61 }, { "log", 61 }, { "var", 61 }, { "log", 61 } }, "each command gets the request buffer") +end + return T diff --git a/tests/repository/test_init.lua b/tests/repository/test_init.lua index 68a78f1..53aca3f 100644 --- a/tests/repository/test_init.lua +++ b/tests/repository/test_init.lua @@ -23,7 +23,13 @@ local function is_git_repo() return result end ----@param opts? { remotes?: string[], enableRemoteUrlRewrites?: boolean, ssh_aliases?: table } +---@class tests.DiscoverOptions +---@field remotes? string[] +---@field enableRemoteUrlRewrites? boolean +---@field ssh_aliases? table +---@field hosts? string[] + +---@param opts? tests.DiscoverOptions ---@return cmp_git.RepositoryMetadata local function discover(opts) opts = opts or {} @@ -31,6 +37,7 @@ local function discover(opts) repository.discover(opts.remotes or { "upstream", "origin" }, { enableRemoteUrlRewrites = opts.enableRemoteUrlRewrites or false, ssh_aliases = opts.ssh_aliases or {}, + hosts = opts.hosts, on_complete = function(result) metadata = result end, @@ -54,10 +61,37 @@ local function edit(path, filetype) end end +---Make a new buffer without a name current. Discovery caches its result for each buffer, so this keeps the result of +---an earlier case for the same directory out of the case. +local function new_buffer() + vim.api.nvim_set_current_buf(vim.api.nvim_create_buf(false, true)) +end + +---Record the commands that start a process, until the end of the case. +---@return string[][] commands +local function record_processes() + local commands = {} + local system = vim.system + helpers.stub(vim, "system", function(cmd, ...) + table.insert(commands, cmd) + return system(cmd, ...) + end) + return commands +end + +---@param commands string[][] +---@return string[] lines Each command as one line, to show in a failure. +local function command_lines(commands) + return vim.tbl_map(function(cmd) + return table.concat(cmd, " ") + end, commands) +end + T["is_git_repo_in_work_tree"] = function() require_git() local repo = helpers.git_repo() vim.fn.mkdir(repo.dir .. "/sub", "p") + new_buffer() vim.fn.chdir(repo.dir .. "/sub") eq(is_git_repo(), true, "a subdirectory of a work tree is in a repository") @@ -65,6 +99,7 @@ end T["is_git_repo_outside_repository"] = function() require_git() + new_buffer() vim.fn.chdir(helpers.tempdir()) eq(is_git_repo(), false, "a directory outside a repository") @@ -73,6 +108,7 @@ end T["is_git_repo_inside_git_dir"] = function() require_git() local repo = helpers.git_repo() + new_buffer() vim.fn.chdir(repo.dir .. "/.git") eq(is_git_repo(), true, "the .git directory is in a repository") @@ -96,6 +132,57 @@ T["is_git_repo_falls_back_to_cwd"] = function() eq(is_git_repo(), true, "the cwd is checked when the buffer directory is not in a repository") end +T["get_cwd_buffer_in_missing_directory"] = function() + require_git() + local repo = helpers.git_repo({ remotes = { { "origin", "https://github.com/owner/repo.git" } } }) + vim.fn.chdir(repo.dir) + edit(helpers.tempdir() .. "/new/dir/notes.md", "markdown") + + eq(repository.get_cwd(), repo.dir, "a directory that does not exist falls back to the cwd") + eq(is_git_repo(), true, "the cwd repository") + eq(discover(), { host = "github.com", owner = "owner", repo = "repo" }, "the cwd repository") +end + +---Run `git rev-parse --show-toplevel` as the completion commands run. +---@return string? toplevel +local function command_toplevel() + local result + local job = require("cmp_git.command").build( + { exec = "git", args = { "rev-parse", "--show-toplevel" } }, + function(stdout) + result = vim.trim(stdout) + end + ) + helpers.not_nil(job, "git command"):start() + helpers.wait_for(function() + return result ~= nil + end, "the command calls back") + return result +end + +T["get_cwd_is_the_repository_directory"] = function() + require_git() + local repo = helpers.git_repo() + edit(helpers.tempdir() .. "/notes.md", "markdown") + vim.fn.chdir(repo.dir) + + eq(is_git_repo(), true, "the cwd repository") + eq(repository.get_cwd(), repo.dir, "the directory where discovery found the repository") + eq(command_toplevel(), repo.dir, "the commands run in the repository") +end + +T["get_cwd_is_the_buffer_repository_directory"] = function() + require_git() + local repo = helpers.git_repo() + local other = helpers.git_repo() + vim.fn.chdir(other.dir) + edit(repo.dir .. "/notes.md", "markdown") + + eq(is_git_repo(), true, "the buffer repository") + eq(repository.get_cwd(), repo.dir, "the buffer directory") + eq(command_toplevel(), repo.dir, "the commands run in the buffer repository") +end + T["discover_first_remote_matches"] = function() require_git() local repo = helpers.git_repo({ @@ -104,6 +191,7 @@ T["discover_first_remote_matches"] = function() { "upstream", "https://github.com/owner/repo.git" }, }, }) + new_buffer() vim.fn.chdir(repo.dir) eq(discover(), { host = "github.com", owner = "owner", repo = "repo" }, "the first configured remote wins") @@ -112,6 +200,7 @@ end T["discover_falls_back_to_next_remote"] = function() require_git() local repo = helpers.git_repo({ remotes = { { "origin", "git@gitlab.com:group/project.git" } } }) + new_buffer() vim.fn.chdir(repo.dir) eq(discover(), { host = "gitlab.com", owner = "group", repo = "project" }, "a missing remote falls back") @@ -125,6 +214,7 @@ T["discover_skips_unparsable_remote"] = function() { "origin", "https://github.com/owner/repo.git" }, }, }) + new_buffer() vim.fn.chdir(repo.dir) eq(discover(), { host = "github.com", owner = "owner", repo = "repo" }, "a local path remote falls back") @@ -133,6 +223,7 @@ end T["discover_without_matching_remote"] = function() require_git() local repo = helpers.git_repo({ remotes = { { "fork", "https://github.com/owner/repo.git" } } }) + new_buffer() vim.fn.chdir(repo.dir) eq(discover().host, nil, "no configured remote, no host") @@ -142,6 +233,7 @@ T["discover_with_remote_url_rewrites"] = function() require_git() local repo = helpers.git_repo({ remotes = { { "origin", "work:owner/repo" } } }) repo.git("config", "url.https://github.example.com/.insteadOf", "work:") + new_buffer() vim.fn.chdir(repo.dir) eq(discover().host, "work", "the configured URL without rewrites") @@ -155,6 +247,7 @@ end T["discover_with_ssh_aliases"] = function() require_git() local repo = helpers.git_repo({ remotes = { { "origin", "git@github-work:owner/repo.git" } } }) + new_buffer() vim.fn.chdir(repo.dir) eq(discover().host, "github-work", "the alias without ssh_aliases") @@ -165,6 +258,159 @@ T["discover_with_ssh_aliases"] = function() ) end +T["discover_with_ssh_aliases_as_plain_strings"] = function() + require_git() + local repo = helpers.git_repo({ remotes = { { "origin", "git@gh+work:owner/repo.git" } } }) + new_buffer() + vim.fn.chdir(repo.dir) + + eq(discover({ ssh_aliases = { ["gh+work"] = "github.com" } }).host, "github.com", "an alias with a +") + eq(discover({ ssh_aliases = { ["gh+work"] = "git%1.example.com" } }).host, "git%1.example.com", "a host with a %") + eq(discover({ ssh_aliases = { ["gh.work"] = "github.com" } }).host, "gh+work", "a . in an alias is not a pattern") +end + +---Make the cwd a repository with the remote `origin`, in a new buffer. +---@param url string +local function with_origin(url) + require_git() + local repo = helpers.git_repo({ remotes = { { "origin", url } } }) + new_buffer() + vim.fn.chdir(repo.dir) +end + +T["discover_with_host_prefix"] = function() + with_origin("https://example.com/gitlab/group/repo.git") + local hosts = { "github.com", "gitlab.com", "example.com/gitlab" } + + eq(discover(), { host = "example.com", owner = "gitlab/group", repo = "repo" }, "the URL without hosts") + eq( + discover({ hosts = hosts }), + { host = "example.com/gitlab", owner = "group", repo = "repo" }, + "the prefix moves from the owner to the host" + ) + eq(discover().owner, "gitlab/group", "the prefix does not change the cache") +end + +T["discover_with_host_prefix_of_several_segments"] = function() + with_origin("https://example.com/tools/gitlab/group/repo.git") + + eq( + discover({ hosts = { "example.com/tools/gitlab" } }), + { host = "example.com/tools/gitlab", owner = "group", repo = "repo" }, + "a prefix with two segments" + ) +end + +T["discover_with_longest_host_prefix"] = function() + with_origin("https://example.com/gitlab/internal/group/repo.git") + local expected = { host = "example.com/gitlab/internal", owner = "group", repo = "repo" } + + eq( + discover({ hosts = { "example.com/gitlab", "example.com/gitlab/internal" } }), + expected, + "the longer prefix last" + ) + eq( + discover({ hosts = { "example.com/gitlab/internal", "example.com/gitlab" } }), + expected, + "the longer prefix first" + ) +end + +T["discover_with_host_prefix_and_subgroups"] = function() + with_origin("https://example.com/gitlab/group/sub/repo.git") + + eq( + discover({ hosts = { "example.com/gitlab" } }), + { host = "example.com/gitlab", owner = "group/sub", repo = "repo" }, + "the subgroups stay in the owner" + ) +end + +T["discover_with_host_prefix_and_port"] = function() + with_origin("https://example.com:8443/gitlab/group/repo.git") + + eq( + discover({ hosts = { "example.com/gitlab", "example.com:8443/gitlab" } }), + { host = "example.com:8443/gitlab", owner = "group", repo = "repo" }, + "the entry with the port" + ) + eq( + discover({ hosts = { "example.com/gitlab" } }), + { host = "example.com:8443", owner = "gitlab/group", repo = "repo" }, + "an entry without the port does not apply" + ) +end + +T["discover_with_host_prefix_and_ipv6_address"] = function() + with_origin("https://[::1]:8443/gitlab/group/repo.git") + + eq( + discover({ hosts = { "[::1]:8443/gitlab" } }), + { host = "[::1]:8443/gitlab", owner = "group", repo = "repo" }, + "an IPv6 address is not a pattern" + ) +end + +T["discover_ssh_remote_with_host_prefix"] = function() + with_origin("git@example.com:group/repo.git") + local expected = { host = "example.com/gitlab", owner = "group", repo = "repo" } + + eq(discover({ hosts = { "github.com", "gitlab.com", "example.com/gitlab" } }), expected, "the only prefixed entry") + eq(discover({ hosts = { "example.com/gitlab", "example.com/gitlab" } }), expected, "the same entry two times") + eq( + discover({ hosts = { "example.com/gitlab", "example.com/tools/gitlab" } }), + { host = "example.com", owner = "group", repo = "repo" }, + "two prefixed entries" + ) +end + +T["discover_ssh_alias_with_host_prefix"] = function() + with_origin("git@gitlab-work:group/repo.git") + + eq( + discover({ ssh_aliases = { ["gitlab-work"] = "example.com" }, hosts = { "example.com/gitlab" } }), + { host = "example.com/gitlab", owner = "group", repo = "repo" }, + "the prefix applies to the real host" + ) +end + +T["discover_with_plain_host_and_host_prefix"] = function() + with_origin("git@example.com:group/repo.git") + + eq( + discover({ hosts = { "example.com", "example.com/gitlab" } }), + { host = "example.com", owner = "group", repo = "repo" }, + "the plain host is an instance too" + ) +end + +T["discover_with_owner_equal_to_host_prefix"] = function() + with_origin("https://example.com/gitlab/repo.git") + + eq( + discover({ hosts = { "example.com", "example.com/gitlab" } }), + { host = "example.com", owner = "gitlab", repo = "repo" }, + "no owner is left after the prefix" + ) + eq( + discover({ hosts = { "example.com/gitlab" } }), + { host = "example.com/gitlab", owner = "gitlab", repo = "repo" }, + "the only prefixed entry keeps the owner" + ) +end + +T["discover_with_host_prefix_of_other_host"] = function() + with_origin("https://gitlab.com/gitlab/group/repo.git") + + eq( + discover({ hosts = { "github.com", "gitlab.com", "example.com/gitlab", "gitlab.co/gitlab" } }), + { host = "gitlab.com", owner = "gitlab/group", repo = "repo" }, + "the entries of other hosts do not apply" + ) + eq(discover({ hosts = { "gitlabXcom/gitlab" } }).host, "gitlab.com", "a . in a host is not a pattern") +end + T["discover_octo_buffer"] = function() local octo_config = { values = { github_hostname = "" } } helpers.stub(package.loaded, "octo.config", octo_config) @@ -205,4 +451,201 @@ T["discover_inside_git_dir"] = function() eq(discover(), { host = "github.com", owner = "owner", repo = "repo" }, "a commit message in .git") end +---What core does for each completion request. +---@param opts? tests.DiscoverOptions +---@return cmp_git.RepositoryMetadata? +local function request(opts) + if not is_git_repo() then + return nil + end + return discover(opts) +end + +T["discovery_runs_git_without_a_shell"] = function() + require_git() + local repo = helpers.git_repo({ remotes = { { "origin", "work:owner/repo" } } }) + new_buffer() + vim.fn.chdir(repo.dir) + local commands = record_processes() + + request() + request({ enableRemoteUrlRewrites = true }) + + helpers.ok(#commands > 0, "discovery runs git") + for _, cmd in ipairs(commands) do + eq(cmd[1], "git", "git runs without sh: " .. table.concat(cmd, " ")) + end +end + +T["discovery_starts_two_processes"] = function() + require_git() + local repo = helpers.git_repo({ remotes = { { "origin", "https://github.com/owner/repo.git" } } }) + local bare = helpers.git_repo() + vim.fn.chdir(helpers.tempdir()) + + edit(repo.dir .. "/notes.md", "markdown") + local commands = record_processes() + eq(request(), { host = "github.com", owner = "owner", repo = "repo" }, "the second remote") + eq(#commands, 2, "a request in a new buffer: " .. vim.inspect(command_lines(commands))) + + edit(bare.dir .. "/notes.md", "markdown") + commands = record_processes() + eq(request(), {}, "a repository without remotes") + eq(#commands, 2, "a repository without remotes: " .. vim.inspect(command_lines(commands))) + + edit(repo.dir .. "/other.md", "markdown") + commands = record_processes() + eq(request({ enableRemoteUrlRewrites = true }).owner, "owner", "the URL from git remote") + eq(#commands, 2, "URL rewrites: " .. vim.inspect(command_lines(commands))) +end + +T["discovery_repeated_request_starts_no_process"] = function() + require_git() + local repo = helpers.git_repo({ remotes = { { "origin", "work:owner/repo" } } }) + repo.git("config", "url.https://github.example.com/.insteadOf", "work:") + edit(repo.dir .. "/notes.md", "markdown") + local expected = request() + local rewritten = request({ enableRemoteUrlRewrites = true }) + local commands = record_processes() + + eq(request(), expected, "the same repository") + eq(request({ enableRemoteUrlRewrites = true }), rewritten, "the same rewritten repository") + eq(request({ ssh_aliases = { work = "github.com" } }).host, "github.com", "ssh_aliases apply to the cache") + eq(request().host, "work", "ssh_aliases do not change the cache") + eq(#commands, 0, "repeated requests: " .. vim.inspect(command_lines(commands))) +end + +T["discovery_outside_repository_starts_no_process"] = function() + require_git() + local dir = helpers.tempdir() + new_buffer() + vim.fn.chdir(dir) + eq(is_git_repo(), false, "a directory outside a repository") + local commands = record_processes() + + eq(is_git_repo(), false, "still outside a repository") + eq(discover(), {}, "no repository") + eq(#commands, 0, "repeated requests: " .. vim.inspect(command_lines(commands))) + + vim.system({ "git", "init", "-q" }, { cwd = dir }):wait() + local now = vim.uv.now() + helpers.stub(vim.uv, "now", function() + return now + 60 * 1000 + end) + eq(is_git_repo(), true, "a directory outside a repository is checked again later, after git init") +end + +T["discovery_concurrent_requests_share_processes"] = function() + require_git() + local repo = helpers.git_repo({ remotes = { { "origin", "https://github.com/owner/repo.git" } } }) + edit(repo.dir .. "/notes.md", "markdown") + local commands = record_processes() + + local results = {} + for _ = 1, 3 do + repository.discover({ "upstream", "origin" }, { + enableRemoteUrlRewrites = false, + ssh_aliases = {}, + on_complete = function(metadata) + table.insert(results, metadata) + end, + }) + end + helpers.wait_for(function() + return #results == 3 + end, "each discover calls back") + + for _, metadata in ipairs(results) do + eq(metadata, { host = "github.com", owner = "owner", repo = "repo" }, "each request gets the repository") + end + helpers.ok(results[1] ~= results[2], "each request gets its own table, because callers change it") + eq(#commands, 2, "concurrent requests: " .. vim.inspect(command_lines(commands))) +end + +T["discovery_octo_buffer_starts_no_process"] = function() + helpers.stub(package.loaded, "octo.config", { values = { github_hostname = "" } }) + vim.fn.chdir(helpers.tempdir()) + edit("octo://owner/repo/issue/12", "octo") + local commands = record_processes() + + eq(request(), { host = "github.com", owner = "owner", repo = "repo" }, "the repository of the buffer name") + eq(#commands, 0, "an octo buffer: " .. vim.inspect(command_lines(commands))) +end + +T["discovery_again_when_buffer_directory_changes"] = function() + require_git() + local first = helpers.git_repo({ remotes = { { "origin", "https://github.com/first/repo.git" } } }) + local second = helpers.git_repo({ remotes = { { "origin", "https://github.com/second/repo.git" } } }) + edit(first.dir .. "/notes.md", "markdown") + eq(request().owner, "first", "the first repository") + + vim.api.nvim_buf_set_name(0, second.dir .. "/notes.md") + eq(request().owner, "second", "the buffer is now in the second repository") +end + +T["discovery_again_when_cwd_changes"] = function() + require_git() + local first = helpers.git_repo({ remotes = { { "origin", "https://github.com/first/repo.git" } } }) + local second = helpers.git_repo({ remotes = { { "origin", "https://github.com/second/repo.git" } } }) + new_buffer() + vim.fn.chdir(first.dir) + eq(request().owner, "first", "the first repository") + + vim.fn.chdir(second.dir) + eq(request().owner, "second", "the cwd is now the second repository") +end + +T["discovery_again_when_remotes_change"] = function() + require_git() + local repo = helpers.git_repo({ remotes = { { "origin", "https://github.com/fork/repo.git" } } }) + edit(repo.dir .. "/notes.md", "markdown") + eq(request().owner, "fork", "the origin remote") + + repo.git("remote", "add", "upstream", "https://github.com/owner/repo.git") + eq(request().owner, "owner", "the new upstream remote comes first") +end + +T["discovery_remote_name_with_a_dot"] = function() + require_git() + local repo = helpers.git_repo({ remotes = { { "my.fork", "https://github.com/fork/repo.git" } } }) + edit(repo.dir .. "/notes.md", "markdown") + + eq(request({ remotes = { "my.fork" } }).owner, "fork", "the configured URL") + eq(request({ remotes = { "my.fork" }, enableRemoteUrlRewrites = true }).owner, "fork", "the URL from git remote") +end + +T["discovery_reads_remotes_of_the_found_repository"] = function() + require_git() + local buffer_repo = helpers.git_repo() + local cwd_repo = helpers.git_repo({ remotes = { { "origin", "https://github.com/cwd/repo.git" } } }) + vim.fn.chdir(cwd_repo.dir) + edit(buffer_repo.dir .. "/notes.md", "markdown") + + eq(request(), {}, "the remotes of another repository do not apply") +end + +T["a_wiped_buffer_has_no_repository"] = function() + require_git() + local repo = helpers.git_repo({ remotes = { { "origin", "https://github.com/owner/repo.git" } } }) + vim.fn.chdir(repo.dir) + local bufnr = helpers.buffer({}, "markdown") + vim.api.nvim_buf_delete(bufnr, { force = true }) + + local results = {} + repository.is_git_repo(function(value) + results.is_git_repo = value + end, bufnr) + repository.discover({ "origin" }, { + bufnr = bufnr, + enableRemoteUrlRewrites = false, + ssh_aliases = {}, + on_complete = function(metadata) + results.discover = metadata + end, + }) + + eq(results, { is_git_repo = false, discover = {} }, "a wiped buffer answers at once, without a repository") + eq(repository.get_cwd(bufnr), repo.dir, "the commands of a wiped buffer run in the cwd") +end + return T diff --git a/tests/repository/test_remote_url.lua b/tests/repository/test_remote_url.lua index be09152..a096dfd 100644 --- a/tests/repository/test_remote_url.lua +++ b/tests/repository/test_remote_url.lua @@ -27,4 +27,62 @@ T["parse_remote_url"] = function() eq(alias.repo, "repo", "alias remote repo") end +T["parse_remote_url_repo_with_git_in_name"] = function() + eq( + remote_url.parse("https://github.com/user/user.github.io.git"), + { host = "github.com", owner = "user", repo = "user.github.io" }, + "only a trailing .git is removed" + ) + eq(remote_url.parse("git@github.com:owner/git.git").repo, "git", "a repo named git") +end + +T["parse_remote_url_subgroups"] = function() + local expected = { host = "gitlab.com", owner = "group/sub", repo = "repo" } + eq(remote_url.parse("https://gitlab.com/group/sub/repo"), expected, "https remote in a subgroup") + eq(remote_url.parse("https://gitlab.com/group/sub/repo.git"), expected, "https remote in a subgroup with .git") + eq(remote_url.parse("git@gitlab.com:group/sub/repo.git"), expected, "scp-like remote in a subgroup") + eq(remote_url.parse("ssh://git@gitlab.com/group/sub/repo.git"), expected, "ssh url remote in a subgroup") +end + +T["parse_remote_url_ports"] = function() + eq( + remote_url.parse("ssh://git@host:2222/group/sub/repo"), + { host = "host", owner = "group/sub", repo = "repo" }, + "the ssh port is not part of the host" + ) + eq( + remote_url.parse("https://gitlab.example.com:8443/group/repo.git"), + { host = "gitlab.example.com:8443", owner = "group", repo = "repo" }, + "the https port is part of the host, because the API is on the same origin" + ) + eq(remote_url.parse("git://example.com:9418/owner/repo.git").host, "example.com", "the git daemon port") +end + +T["parse_remote_url_credentials"] = function() + eq( + remote_url.parse("https://user:token@github.com/o/r"), + { host = "github.com", owner = "o", repo = "r" }, + "credentials are not part of the host" + ) + eq(remote_url.parse("https://token@github.com:8443/o/r").host, "github.com:8443", "a token and a port") + eq(remote_url.parse("org-123@github.com:owner/repo.git").host, "github.com", "an scp-like user other than git") + eq(remote_url.parse("git+ssh://git@github.com/owner/repo.git").host, "github.com", "a git+ssh remote") +end + +T["parse_remote_url_trailing_slash"] = function() + local expected = { host = "github.com", owner = "owner", repo = "repo" } + eq(remote_url.parse("https://github.com/owner/repo/"), expected, "a trailing slash") + eq(remote_url.parse("https://github.com/owner/repo.git/"), expected, "a trailing slash after .git") + eq(remote_url.parse("git@github.com:owner/repo.git\n"), expected, "a trailing newline") +end + +T["parse_remote_url_unsupported"] = function() + eq(remote_url.parse(nil), {}, "no URL") + eq(remote_url.parse(""), {}, "an empty URL") + eq(remote_url.parse("/srv/git/project.git"), {}, "a local path") + eq(remote_url.parse("./project"), {}, "a relative path") + eq(remote_url.parse("file:///srv/git/owner/project.git"), {}, "a file URL") + eq(remote_url.parse("https://github.com/repo"), {}, "a URL without an owner") +end + return T diff --git a/tests/sources/test_git.lua b/tests/sources/test_git.lua index ba01cac..af9e86c 100644 --- a/tests/sources/test_git.lua +++ b/tests/sources/test_git.lua @@ -23,4 +23,83 @@ T["git_instance_state_isolation"] = function() ok(first.cache_trailer_values ~= second.cache_trailer_values, "Git trailer values cache is instance-local") end +---Starts the requests that a commit message buffer can make, and records the lists that each one gives. +---@param git cmp_git.Source.Git +---@return table lists +local function request_all(git) + local bufnr = helpers.buffer({ "feat: subject", "", "" }) + local lists = { commits = {}, keys = {}, identities = {}, values = {} } + local function context(line) + return { bufnr = bufnr, cursor = { row = 3, character = #line }, line = line } + end + local function record(name) + return function(list) + table.insert(lists[name], list) + end + end + + ok(git:get_commits(record("commits"), context("See :"), ":"), "commits are handled") + ok(git:get_trailer_keys(record("keys"), context("Si")), "trailer keys are handled") + ok(git:get_trailer_identities(record("identities"), context("Co-authored-by: Te")), "identities are handled") + ok(git:get_trailer_values(record("values"), context("Assisted-by: C")), "trailer values are handled") + return lists +end + +---@param list cmp_git.CompletionList +---@return string[] +local function labels(list) + local result = {} + for _, item in ipairs(list.items) do + table.insert(result, item.label) + end + return result +end + +T["git_requests_answer_in_a_repository_without_commits"] = function() + if vim.fn.executable("git") ~= 1 then + helpers.skip("git is not installed") + end + + local repo = helpers.git_repo() + vim.env.GIT_AUTHOR_NAME = "Test Self" + vim.env.GIT_AUTHOR_EMAIL = "self@example.com" + vim.fn.chdir(repo.dir) + + local git_config = config.normalize({}).git + local git = Git.new(git_config) + local lists = request_all(git) + helpers.wait_for(function() + return #lists.commits > 0 and #lists.keys > 0 and #lists.identities > 0 and #lists.values > 0 + end, "each request calls back") + helpers.flush() + + eq(lists.commits, { { items = {}, isIncomplete = false } }, "commits give one final empty list") + eq(#lists.keys, 1, "trailer keys call back once") + eq(#lists.keys[1].items, #git_config.trailers.keys.builtin, "trailer keys give the built-in keys") + eq(#lists.identities, 1, "identities call back once") + eq(labels(lists.identities[1]), { "Test Self " }, "identities give your identity") + eq(lists.values, { { items = {}, isIncomplete = false } }, "trailer values give one final empty list") + + eq(next(git.cache_commits), nil, "commits are not cached") + eq(next(git.cache_trailer_keys), nil, "trailer keys are not cached") + eq(next(git.cache_trailer_identities), nil, "identities are not cached") + eq(next(git.cache_trailer_values), nil, "trailer values are not cached") +end + +T["git_requests_answer_without_git"] = function() + -- `command.build` gives no job when git is not installed + helpers.stub(vim.fn, "executable", function() + return 0 + end) + + local git_config = config.normalize({}).git + local lists = request_all(Git.new(git_config)) + + eq(lists.commits, { { items = {}, isIncomplete = false } }, "commits give one final empty list") + eq(#lists.keys, 1, "trailer keys call back once") + eq(#lists.keys[1].items, #git_config.trailers.keys.builtin, "trailer keys give the built-in keys") + eq(lists.identities, { { items = {}, isIncomplete = false } }, "identities give one final empty list") + eq(lists.values, { { items = {}, isIncomplete = false } }, "trailer values give one final empty list") +end + return T diff --git a/tests/sources/test_github.lua b/tests/sources/test_github.lua index 855d2c7..06362ad 100644 --- a/tests/sources/test_github.lua +++ b/tests/sources/test_github.lua @@ -1,6 +1,7 @@ local helpers = require("tests.helpers") local command = require("cmp_git.command") local config = require("cmp_git.config") +local repository = require("cmp_git.repository") local GitHub = require("cmp_git.sources.github") local eq = helpers.eq @@ -37,28 +38,43 @@ end local git_info = { host = "github.com", owner = "owner", repo = "repo" } +---Make PATH a directory with only these commands until the end of the case. +---@param scripts table The shell script body of each command, by name. +---@return string bin The directory. +local function fake_commands(scripts) + local bin = helpers.tempdir() + for name, body in pairs(scripts) do + local path = bin .. "/" .. name + local file = assert(io.open(path, "w")) + file:write("#!/bin/sh\n" .. body .. "\n") + file:close() + vim.uv.fs_chmod(path, tonumber("755", 8)) + end + helpers.stub(vim.env, "PATH", bin) + return bin +end + +local records = { + issue = { number = 1, title = "an issue", body = "", updatedAt = "2024-01-01T00:00:00Z" }, + pr = { number = 2, title = "a pull request", body = "", updatedAt = "2024-01-01T00:00:00Z" }, +} + ---A GitHub source whose `gh` commands give one issue and one pull request. `requests` counts the commands that ran. ---@return cmp_git.Source.GitHub source ---@return { issue: integer, pr: integer } requests local function fake_github() local source = GitHub.new(config.normalize({}).github) local requests = { issue = 0, pr = 0 } - local records = { - issue = { number = 1, title = "an issue", body = "", updatedAt = "2024-01-01T00:00:00Z" }, - pr = { number = 2, title = "a pull request", body = "", updatedAt = "2024-01-01T00:00:00Z" }, - } - - -- Do not run `gh repo set-default` - helpers.stub(source, "_normalize_git_info", function(_, info) - return info - end) + + -- No gh on PATH, so that the default repository lookup does not run + fake_commands({}) helpers.stub(command, "build_fallback_list", function(commands, callback, handle_item) local kind = commands[1].args[1] return { command = "fake", start = function(_, on_complete) requests[kind] = requests[kind] + 1 - callback({ items = { handle_item(vim.deepcopy(records[kind])) }, isIncomplete = false }) + callback({ items = { handle_item(vim.deepcopy(records[kind])) }, isIncomplete = false }, true) if on_complete then on_complete(true) end @@ -114,4 +130,283 @@ T["issues_and_change_requests_after_a_cached_change_request"] = function() eq(requests, { issue = 1, pr = 1 }, "each command runs once") end +---@class tests.GitHubJob: cmp_git.SystemJob +---@field started boolean +---@field cancelled boolean +---@field respond fun(success: boolean) Complete the job with its record, or fail it. + +---A GitHub source whose `gh` jobs wait until the case completes them. +---@return cmp_git.Source.GitHub source +---@return table jobs The last job of each kind, `issue` or `pr`. +local function manual_github() + local source = GitHub.new(config.normalize({}).github) + local jobs = {} + + -- No gh on PATH, so that the default repository lookup does not run + fake_commands({}) + helpers.stub(command, "build_fallback_list", function(commands, callback, handle_item) + local kind = commands[1].args[1] + local job = { command = "fake", started = false, cancelled = false } + function job.start() + job.started = true + end + function job.cancel() + job.cancelled = true + end + function job.respond(success) + if success then + callback({ items = { handle_item(vim.deepcopy(records[kind])) }, isIncomplete = false }, true) + else + callback({ items = {}, isIncomplete = false }, false) + end + end + jobs[kind] = job + return job + end) + + return source, jobs +end + +T["issues_and_change_requests_start_at_once"] = function() + local source, jobs = manual_github() + local callback, lists = helpers.recorder() + + source:complete_issues_and_change_requests(callback, git_info, "#") + eq({ jobs.issue.started, jobs.pr.started }, { true, true }, "both commands run at once") + + jobs.pr.respond(true) + eq(#lists, 0, "the list waits for the issues") + jobs.issue.respond(true) + + eq(#lists, 1, "one list") + eq(labels(lists[1].items), { "#1: an issue", "#2: a pull request" }, "issues, then pull requests") +end + +T["issues_and_change_requests_keep_the_items_of_a_request_that_succeeds"] = function() + for _, failed in ipairs({ "issue", "pr" }) do + local source, jobs = manual_github() + local callback, lists = helpers.recorder() + + source:complete_issues_and_change_requests(callback, git_info, "#") + jobs.issue.respond(failed ~= "issue") + jobs.pr.respond(failed ~= "pr") + + eq(#lists, 1, failed .. " failed: one list") + eq( + labels(lists[1].items), + failed == "issue" and { "#2: a pull request" } or { "#1: an issue" }, + failed .. " failed: the items of the other request" + ) + end +end + +T["issues_and_change_requests_cancel_both"] = function() + local source, jobs = manual_github() + local callback, lists = helpers.recorder() + + local _, controller = source:complete_issues_and_change_requests(callback, git_info, "#") + helpers.not_nil(controller):cancel() + + eq({ jobs.issue.cancelled, jobs.pr.cancelled }, { true, true }, "both jobs are cancelled") + eq(lists, {}, "a cancelled request gets no list") +end + +---A GitHub source whose jobs answer at once and record the repository that they ask for. With `default_repo`, a fake +---`gh repo set-default --view` prints it and records the directory that it runs in. +---@param default_repo? string nil for no gh on PATH. +---@return cmp_git.Source.GitHub source +---@return string[] repos The `owner/repo` of each job, in order. +---@return fun(): string[] gh_calls The directories of the `gh repo set-default` calls so far. +local function recording_github(default_repo) + local log_path = helpers.tempdir() .. "/gh_calls" + local scripts = {} + if default_repo then + scripts.gh = table.concat({ + 'if [ "$1 $2 $3" != "repo set-default --view" ]; then exit 1; fi', + string.format("pwd -P >> '%s'", log_path), + string.format("printf '%%s\\n' '%s'", default_repo), + }, "\n") + end + fake_commands(scripts) + + local repos = {} + ---@param args string[] + local function record(args) + -- `gh issue list --repo host/owner/repo` or `gh api repos/owner/repo/...` + if args[1] == "api" then + table.insert(repos, args[2]:match("^repos/([^/]+/[^/]+)/")) + else + table.insert(repos, args[4]:match("^[^/]+/(.+)$")) + end + end + helpers.stub(command, "build_fallback_list", function(commands, callback) + record(commands[1].args) + return helpers.fake_job(function() + callback({ items = {}, isIncomplete = false }, true) + end) + end) + helpers.stub(command, "build_fallback", function(commands, callback) + record(commands[1].args) + return helpers.fake_job(function() + callback("", false) + end) + end) + + local function gh_calls() + if vim.fn.filereadable(log_path) == 0 then + return {} + end + return vim.fn.readfile(log_path) + end + + return GitHub.new(config.normalize({}).github), repos, gh_calls +end + +local REQUESTS = { "complete_issues", "complete_change_requests", "complete_issues_and_change_requests" } + +---A new git info for each request, so that a change by one request cannot reach the next one. +---@return cmp_git.GitInfo +local function remote() + return { host = "github.com", owner = "owner", repo = "repo" } +end + +T["the_default_repository_is_looked_up_once_without_blocking"] = function() + local source, repos, gh_calls = recording_github("upstream/project") + local cwd = helpers.tempdir() + helpers.stub(repository, "get_cwd", function() + return cwd + end) + local callback, lists = helpers.recorder() + + local info = remote() + eq(source:complete_issues(callback, info, "#"), true, "the host is handled") + eq(#lists, 0, "the request does not wait for gh") + helpers.wait_for(function() + return #lists == 1 + end, "the issues answer") + eq(repos, { "upstream/project" }, "the issues come from the default repository") + 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")) + source:complete_mentions(callback, remote(), "@") + source:complete_issues_and_change_requests(callback, remote(), "#") + + eq(#lists, 3, "the known default repository answers at once") + eq(#repos, 5, "issues, mention preflight, mentions, issues, pull requests") + for i, repo in ipairs(repos) do + eq(repo, "upstream/project", "request " .. i .. " uses the default repository") + end + eq(gh_calls(), { cwd }, "gh runs once for the repository") +end + +T["requests_during_the_lookup_share_it"] = function() + local source, repos, gh_calls = recording_github("upstream/project") + local callback, lists = helpers.recorder() + + source:complete_issues(callback, remote(), "#") + source:complete_mentions(callback, remote(), "@") + helpers.wait_for(function() + return #lists == 2 + end, "both requests answer") + + eq(#gh_calls(), 1, "one gh call") + eq(repos, { "upstream/project", "upstream/project", "upstream/project" }, "the default repository") +end + +T["without_a_default_repository_the_remote_is_used"] = function() + local source, repos, gh_calls = recording_github("") + local callback, lists = helpers.recorder() + + source:complete_issues(callback, remote(), "#") + helpers.wait_for(function() + return #lists == 1 + end, "the issues answer") + vim.api.nvim_set_current_buf(helpers.buffer({}, "markdown")) + source:complete_issues(callback, remote(), "#") + + eq(#lists, 2, "the second request answers at once") + eq(repos, { "owner/repo", "owner/repo" }, "the remote repository") + eq(#gh_calls(), 1, "gh runs once") +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 } + local callback, lists = helpers.recorder() + + for _, method in ipairs(vim.list_extend({ "complete_mentions" }, REQUESTS)) do + source[method](source, callback, remote(), "#") + end + + eq(#lists, 4, "each cache hit answers at once") + eq(repos, {}, "no command runs") + eq(source.default_repos, {}, "no lookup starts") +end + +T["without_gh_the_remote_is_used"] = function() + local source, repos = recording_github(nil) + local callback, lists = helpers.recorder() + + for _, method in ipairs(REQUESTS) do + vim.api.nvim_set_current_buf(helpers.buffer({}, "markdown")) + source[method](source, callback, remote(), "#") + end + + eq(#lists, 3, "each request answers at once") + eq(repos, { "owner/repo", "owner/repo", "owner/repo", "owner/repo" }, "the remote repository") +end + +T["an_octo_buffer_keeps_its_repository"] = function() + local source, repos, gh_calls = recording_github("upstream/project") + vim.api.nvim_set_current_buf(helpers.buffer({}, "octo")) + local callback, lists = helpers.recorder() + + source:complete_issues(callback, remote(), "#") + source:complete_mentions(callback, remote(), "@") + + eq(#lists, 2, "each request answers at once") + eq(repos, { "owner/repo", "owner/repo", "owner/repo" }, "the repository of the octo buffer") + eq(gh_calls(), {}, "gh does not run") +end + +T["a_cancel_during_the_lookup_starts_no_request"] = function() + local source, repos = recording_github("upstream/project") + local callback, lists = helpers.recorder() + + local _, controller = source:complete_issues(callback, remote(), "#") + helpers.not_nil(controller):cancel() + helpers.wait_for(function() + local lookup = source.default_repos["github.com/owner/repo"] + return lookup ~= nil and lookup.done + end, "the lookup ends") + + eq(repos, {}, "no request starts") + eq(lists, {}, "a cancelled request gets no list") +end + +T["requests_cache_under_the_request_buffer"] = 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 callback, lists = helpers.recorder() + + for _, request in ipairs(REQUESTS) do + source[request](source, callback, remote(), "#", request_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") +end + return T diff --git a/tests/sources/test_gitlab.lua b/tests/sources/test_gitlab.lua index f5297f0..1080028 100644 --- a/tests/sources/test_gitlab.lua +++ b/tests/sources/test_gitlab.lua @@ -34,4 +34,59 @@ T["gitlab_instance_state_isolation"] = function() 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" } +local EMPTY = { items = {}, isIncomplete = false } + +---Make PATH a directory with only these commands until the end of the case. +---@param scripts table The shell script body of each command, by name. +local function fake_commands(scripts) + local bin = helpers.tempdir() + for name, body in pairs(scripts) do + local path = bin .. "/" .. name + local file = assert(io.open(path, "w")) + file:write("#!/bin/sh\n" .. body .. "\n") + file:close() + vim.uv.fs_chmod(path, tonumber("755", 8)) + end + helpers.stub(vim.env, "PATH", bin) +end + +---Run a request of the source and return the lists that it gave. +---@param source cmp_git.Source.Gitlab +---@param method string +---@param trigger_char string +---@return cmp_git.CompletionList[] +local function answers(source, method, trigger_char) + local callback, lists = helpers.recorder() + local handled = source[method](source, callback, git_info, trigger_char) + eq(handled, true, method .. " handles the host") + helpers.wait_for(function() + return #lists > 0 + end, method .. " answers") + helpers.flush() + return lists +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") +end + +T["missing_commands_give_an_empty_list"] = function() + fake_commands({}) + local source = GitLab.new(config.normalize({}).gitlab) + + eq(answers(source, "complete_issues", "#"), { EMPTY }, "issues") + eq(answers(source, "complete_change_requests", "!"), { EMPTY }, "merge requests") + eq(answers(source, "complete_mentions", "@"), { EMPTY }, "mentions") +end + return T diff --git a/tests/test_blink.lua b/tests/test_blink.lua index 53ff9c0..0c54656 100644 --- a/tests/test_blink.lua +++ b/tests/test_blink.lua @@ -46,13 +46,14 @@ T["blink_adapter_api_and_cancellation"] = function() eq(callback_result.is_incomplete_forward, true, "blink forwards incomplete state") cancel() - eq(cancelled, true, "blink cancellation delegates to core job") + eq(cancelled, false, "a request that answered continues to fill the cache") end T["blink_adapter_suppresses_stale_callbacks_after_cancel"] = function() local blink = Blink.new({}) local pending_callback local called = false + local cancelled = false blink.core = { is_available = function() return true @@ -62,7 +63,11 @@ T["blink_adapter_suppresses_stale_callbacks_after_cancel"] = function() end, complete = function(_, _context, callback) pending_callback = callback - return { cancel = function() end } + return { + cancel = function() + cancelled = true + end, + } end, } @@ -75,9 +80,53 @@ T["blink_adapter_suppresses_stale_callbacks_after_cancel"] = function() cancel() pending_callback({ items = { { label = "stale" } }, isIncomplete = false }) + eq(cancelled, true, "blink cancellation delegates to core job") eq(called, false, "blink suppresses stale callback after cancellation") end +T["blink_adapter_forwards_the_first_response_only"] = function() + local blink = Blink.new({}) + blink.core = { + get_trigger_characters = function() + return { "@" } + end, + -- Like the mentions capability: a first page, then the cached list with the next page + complete = function(_, _context, callback) + local first = { label = "@ada" } + callback({ items = { first }, isIncomplete = true }) + callback({ items = { first, { label = "@grace" } }, isIncomplete = false }) + return { cancel = function() end } + end, + } + local responses = {} + + blink:get_completions({ trigger = { character = "@" }, cursor = { 1, 4 }, line = "cc @" }, function(response) + table.insert(responses, response) + end) + + eq(#responses, 1, "blink gets one response for each request") + eq(#responses[1].items, 1, "the response has the first page") + eq(responses[1].is_incomplete_forward, true, "blink asks again on the next keystroke") +end + +T["blink_adapter_special_trigger_characters"] = function() + local blink = Blink.new({}) + local received + blink.core = { + get_trigger_characters = function() + return { "%", "]", "-" } + end, + complete = function(_, context, _callback) + received = context + return { cancel = function() end } + end, + } + + blink:get_completions({ cursor = { 1, 7 }, line = "see %ab", bufnr = 1 }, function() end) + + eq(received.trigger_character, "%", "blink detects a special trigger character") +end + T["blink_adapter_matches_text_before_cursor"] = function() local blink = Blink.new({}) local received diff --git a/tests/test_command.lua b/tests/test_command.lua index e0439ed..4878790 100644 --- a/tests/test_command.lua +++ b/tests/test_command.lua @@ -1,5 +1,6 @@ local helpers = require("tests.helpers") local command = require("cmp_git.command") +local repository = require("cmp_git.repository") local eq = helpers.eq @@ -30,6 +31,71 @@ T["fallback"] = function() eq(result.success, true, "fallback success") end +---@param code integer +---@return cmp_git.CommandSpec +local function exit_with(code) + return { exec = "sh", args = { "-c", "exit " .. code } } +end + +T["fallback_reports_the_last_failure"] = function() + for count = 1, 3 do + local specs = {} + for i = 1, count do + table.insert(specs, exit_with(i)) + end + local calls = {} + local completed + local job = helpers.not_nil(command.build_fallback(specs, function(stdout, success) + table.insert(calls, { stdout = stdout, success = success }) + end)) + + job:start(function(success) + completed = success + end) + helpers.wait_for(function() + return completed ~= nil + end, "the fallback completes") + + eq(calls, { { stdout = "", success = false } }, count .. " failed commands give one failure callback") + eq(completed, false, count .. " failed commands fail the job") + end +end + +T["fallback_list_reports_the_last_failure"] = function() + local calls = {} + local job = helpers.not_nil(command.build_fallback_list({ exit_with(1), exit_with(2) }, function(list, success) + table.insert(calls, { list = list, success = success }) + end, function(item) + return item + end)) + job:start() + helpers.wait_for(function() + return #calls > 0 + end, "the fallback completes") + helpers.flush() + + eq(calls, { { list = { items = {}, isIncomplete = false }, success = false } }, "one empty list") +end + +T["fallback_skips_a_missing_executable"] = function() + local calls = {} + local job = helpers.not_nil( + command.build_fallback( + { exit_with(1), { exec = "cmp-git-missing-executable", args = {} } }, + function(_, success) + table.insert(calls, success) + end + ) + ) + job:start() + helpers.wait_for(function() + return #calls > 0 + end, "the fallback completes") + helpers.flush() + + eq(calls, { false }, "the first command reports its failure when the second cannot run") +end + T["command_fallback_cancels_active_job"] = function() local first_cancelled = false local second_started = false @@ -91,4 +157,118 @@ T["build_job"] = function() eq(list.isIncomplete, false, "json job completion flag") end +T["build_list_reports_an_error_object"] = function() + local calls = {} + local job = helpers.not_nil( + command.build_list( + { exec = "sh", args = { "-c", 'printf \'{"message":"Bad credentials"}\'' } }, + function(list, success) + table.insert(calls, { list = list, success = success }) + end, + function(item) + return item + end + ) + ) + job:start() + helpers.wait_for(function() + return #calls > 0 + end, "the job completes") + + eq(calls, { { list = { items = {}, isIncomplete = false }, success = false } }, "an error object is a failure") +end + +T["build_list_reports_a_failure"] = function() + local calls = {} + local job = helpers.not_nil(command.build_list(exit_with(1), function(list, success) + table.insert(calls, { list = list, success = success }) + end, function(item) + return item + end)) + job:start() + helpers.wait_for(function() + return #calls > 0 + end, "the job completes") + + eq( + calls, + { { list = { items = {}, isIncomplete = false }, success = false } }, + "a failed command gives an empty list" + ) +end + +T["spawn_error_is_a_failed_command"] = function() + helpers.stub(repository, "get_cwd", function() + return helpers.tempdir() .. "/missing" + end) + local result + local completed + local job = helpers.not_nil(command.build({ exec = "sh", args = { "-c", "printf out" } }, function(stdout, success) + result = { stdout = stdout, success = success } + end)) + + job:start(function(success) + completed = success + end) + helpers.wait_for(function() + return completed ~= nil + end, "the job completes") + + eq(result, { stdout = "", success = false }, "the callback gets a failure") + eq(completed, false, "the job fails") +end + +T["stdin"] = function() + local seen + local job = + helpers.not_nil(command.build({ exec = "sh", args = { "-c", "cat" }, stdin = "from stdin\n" }, function(stdout) + seen = stdout + end)) + job:start() + helpers.wait_for(function() + return seen ~= nil + end, "the command completes") + + eq(seen, "from stdin\n", "the command reads the stdin of the spec") +end + +T["env_adds_to_the_environment"] = function() + helpers.stub(vim.env, "CMP_GIT_TEST_INHERITED", "inherited") + local seen + local job = helpers.not_nil(command.build({ + exec = "sh", + args = { "-c", 'printf "%s %s" "$CMP_GIT_TEST_INHERITED" "$CMP_GIT_TEST_ADDED"' }, + env = { CMP_GIT_TEST_ADDED = 1 }, + }, function(stdout) + seen = stdout + end)) + job:start() + helpers.wait_for(function() + return seen ~= nil + end, "the command completes") + + eq(seen, "inherited 1", "the command gets the variables of Neovim and of the spec") +end + +T["build_runs_in_the_repository_of_the_spec_buffer"] = function() + local dir = helpers.tempdir() + local seen + helpers.stub(repository, "get_cwd", function(bufnr) + seen = bufnr + return dir + end) + local result + local job = helpers.not_nil(command.build({ exec = "sh", args = { "-c", "pwd -P" }, bufnr = 42 }, function(stdout) + result = stdout + end)) + + job:start() + helpers.wait_for(function() + return result ~= nil + end, "the job completes") + + eq(seen, 42, "the directory comes from the buffer of the spec") + eq(vim.trim(result), dir, "the command runs in that directory") +end + return T diff --git a/tests/test_config.lua b/tests/test_config.lua index 3645894..b301b4e 100644 --- a/tests/test_config.lua +++ b/tests/test_config.lua @@ -160,6 +160,30 @@ T["config_trailer_defaults"] = function() ok(not vim.tbl_contains(normalized.git.trailers.filetypes, "octo"), "no trailers in octo") end +T["config_trailer_value_pattern_has_no_lookbehind"] = function() + local value_entry = config.normalize({}).trigger_actions[2] + + -- A lookbehind makes Vim use its backtracking engine, which is slow on long lines + eq(value_entry.pattern:find([[\@<=]], 1, true), nil, "value pattern has no lookbehind") + eq(value_entry.keyword_pattern:find([[\@<=]], 1, true), nil, "value keyword pattern has no lookbehind") +end + +T["config_normalize_upgrades_the_lookbehind_value_pattern"] = function() + local lookbehind = [[\%(^[[:alnum:]-]\+:\s*\)\@<=\%([^[:space:]<>:#@!][^<>:#@!]*\%(<[^>]*\)\?\)\?]] + local user_entry = { pattern = lookbehind, actions = { "git_trailer_values" } } + local custom_entry = { pattern = [[^Refs:\s*\zs\S*]], actions = { "git_trailer_values" } } + local defaults = config.normalize({}).trigger_actions[2] + + local normalized = config.normalize({ trigger_actions = { user_entry, custom_entry } }) + + eq(normalized.trigger_actions[1].pattern, defaults.pattern, "lookbehind pattern becomes the default pattern") + eq(normalized.trigger_actions[1].keyword_pattern, defaults.keyword_pattern, "upgrade adds the keyword pattern") + eq(normalized.trigger_actions[1].actions, { "git_trailer_values" }, "upgrade keeps the actions") + eq(user_entry.pattern, lookbehind, "upgrade does not change the user config") + eq(user_entry.keyword_pattern, nil, "upgrade does not add fields to the user config") + ok(normalized.trigger_actions[2] == custom_entry, "other pattern entries are kept") +end + T["config_git_filter_alias_skips_trailers"] = function() local git_filter = function() return "git" diff --git a/tests/test_core.lua b/tests/test_core.lua index b946563..5ff088c 100644 --- a/tests/test_core.lua +++ b/tests/test_core.lua @@ -4,6 +4,21 @@ local Source = require("cmp_git.source") local repository = require("cmp_git.repository") local eq = helpers.eq +local ok = helpers.ok + +-- The repository root, for the scripts that run in another Neovim +local ROOT = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":p:h:h") + +-- Core runs the action of a provider only for the hosts of that provider. A host of both providers lets a case run +-- the GitLab and the GitHub actions of a trigger. +local function shared_hosts() + return { github = { hosts = { "git.example.com" } }, gitlab = { hosts = { "git.example.com" } } } +end + +---@return cmp_git.GitInfo +local function shared_remote() + return { host = "git.example.com", owner = "owner", repo = "repo" } +end -- The cases replace the provider sources with partial fakes ---@diagnostic disable: missing-fields @@ -11,9 +26,10 @@ local eq = helpers.eq local T = helpers.new_set() T["git_commit_edit_range_is_preserved"] = function() - local items = { { insertText = "abcdef1" } } - - Core.apply_text_edits(items, { cursor = { row = 3, character = 5 }, line = ":abcd", trigger_character = ":" }) + local items = Core.apply_text_edits( + { { insertText = "abcdef1" } }, + { cursor = { row = 3, character = 5 }, line = ":abcd", trigger_character = ":" } + ) eq(items[1].textEdit.range.start.line, 2, "commit edit range start line") eq(items[1].textEdit.range.start.character, 0, "commit edit range start character") @@ -31,8 +47,7 @@ T["core_text_edit_ranges_for_all_triggers"] = function() } for _, case in ipairs(cases) do - local items = { { insertText = case.insert } } - Core.apply_text_edits(items, { + local items = Core.apply_text_edits({ { insertText = case.insert } }, { cursor = { row = 1, character = case.cursor }, line = case.line, trigger_character = case.trigger, @@ -45,6 +60,51 @@ T["core_text_edit_ranges_for_all_triggers"] = function() end end +T["requests_get_new_item_tables"] = function() + local data = { number = 12 } + local cached = { { label = "#12: Fix", insertText = "#12", data = data } } + local core = Core.new(shared_hosts()) + core.sources.gitlab = { + complete_issues = function() + return false + end, + } + core.sources.github = { + -- Like a cache hit of the issues capability + complete_issues_and_change_requests = function(_, callback) + callback({ items = cached, isIncomplete = false }) + return true + end, + } + local callback, lists = helpers.recorder() + local function request(line) + core:_run_trigger_actions( + "#", + callback, + { cursor = { row = 1, character = #line }, line = line }, + shared_remote() + ) + return lists[#lists].items[1] + end + + local first = request("see #1") + -- blink.cmp writes these into the items that it gets + first.pos = { 0, 6 } + first.score_offset = 3 + local second = request("x #") + + ok(first ~= cached[1] and second ~= first, "each request gets new item tables") + eq(cached[1].textEdit, nil, "the cached item has no text edit") + eq(first.textEdit.range["end"].character, 6, "a later request does not change the first text edit") + eq(second.textEdit.range.start.character, 2, "the second text edit starts at its trigger") + eq(second.textEdit.range["end"].character, 3, "the second text edit ends at its cursor") + eq(second.pos, nil, "the second item has no position of the first request") + eq(second.score_offset, nil, "the score offset does not grow") + eq(second.label, "#12: Fix", "the copy keeps the label") + ok(second.data == data, "the copy keeps the data") + eq(lists[2].isIncomplete, false, "the list keeps isIncomplete") +end + T["custom_trigger_actions_remove_trailers"] = function() local core = Core.new({ trigger_actions = { @@ -56,6 +116,46 @@ T["custom_trigger_actions_remove_trailers"] = function() eq(core.keyword_pattern, "[:]\\S*", "keyword pattern without pattern entries is unchanged") end +-- Characters that are special in a Vim collection or a Lua pattern set. `^` comes first, where it would negate the +-- set, and `-` between `#` and `@` would make a range that contains `+`. +local SPECIAL_TRIGGER_CHARACTERS = { "^", "#", "-", "@", "%", "]", "\\", "[" } + +---@param characters string[] +---@return table core +local function core_with_triggers(characters) + local trigger_actions = {} + for _, character in ipairs(characters) do + table.insert(trigger_actions, { trigger_character = character, actions = { "git_commits" } }) + end + return Core.new({ trigger_actions = trigger_actions }) +end + +T["trigger_characters_are_escaped"] = function() + local core = core_with_triggers(SPECIAL_TRIGGER_CHARACTERS) + local regex = vim.regex([[\%(]] .. core.keyword_pattern .. [[\)\m$]]) + + for _, character in ipairs(SPECIAL_TRIGGER_CHARACTERS) do + local line = "see " .. character .. "ab" + eq(regex:match_str(line), 4, "keyword offset of " .. vim.inspect(line)) + eq( + Core.find_trigger_character(line, core:get_trigger_characters()), + character, + "trigger character of " .. vim.inspect(line) + ) + end + for _, line in ipairs({ "see ab", "see +ab" }) do + eq(regex:match_str(line), nil, "no keyword offset for " .. vim.inspect(line)) + end + eq(Core.find_trigger_character("see +ab", core:get_trigger_characters()), nil, "no trigger in a range") + + local brackets = core_with_triggers({ "[", "]" }) + eq(vim.regex(brackets.keyword_pattern):match_str("see \\ab"), nil, "brackets do not add a backslash") + + local without_triggers = Core.new({ trigger_actions = {} }) + eq(without_triggers:get_keyword_pattern(), nil, "no keyword pattern without trigger characters") + eq(Core.find_trigger_character("see #ab", {}), nil, "no trigger character without trigger characters") +end + T["invalid_trigger_action_pattern_is_skipped"] = function() local core = Core.new({ trigger_actions = { @@ -74,6 +174,24 @@ T["invalid_trigger_action_pattern_is_skipped"] = function() ) end +T["trigger_action_keyword_pattern"] = function() + local core = Core.new({ + trigger_actions = { + { pattern = [[^Refs:\s*\zs\S*]], keyword_pattern = [[^Refs:\zs\S*]], actions = { "git_trailer_values" } }, + { pattern = [[^Key\S*]], keyword_pattern = [[\%(]], actions = { "git_trailer_keys" } }, + }, + }) + + eq(core.keyword_pattern, [[\%(^Refs:\zs\S*\|^Key\S*\)]], "keyword patterns replace the patterns") + eq(core.trigger_actions[1].regex:match_str("Refs: 12"), 6, "routing uses the pattern") + helpers.flush() + eq( + helpers.notifications[1].message:match("^Invalid trigger action keyword_pattern"), + "Invalid trigger action keyword_pattern", + "invalid keyword pattern logs a warning" + ) +end + T["core_keyword_pattern_offsets"] = function() local core = Core.new({}) local regex = vim.regex([[\%(]] .. core.keyword_pattern .. [[\)\m$]]) @@ -97,8 +215,108 @@ T["core_keyword_pattern_offsets"] = function() end end -T["trailer_routing"] = function() +-- The trailer value pattern of earlier versions. Its lookbehind is slow, so the default changed to a `\zs` form that +-- must give the same results. +local LOOKBEHIND_VALUE_PATTERN = [[\%(^[[:alnum:]-]\+:\s*\)\@<=\%([^[:space:]<>:#@!][^<>:#@!]*\%(<[^>]*\)\?\)\?]] + +---Deterministic lines over the characters that the trailer patterns treat differently. +---@param count integer +---@return string[] +local function random_lines(count) + local chars = { "a", "B", "-", ":", " ", "#", "@", "!", "<", ">", "\t", "é" } + local seed = 1 + local function next_int(n) + seed = (seed * 1103515245 + 12345) % 2147483648 + return seed % n + 1 + end + + local lines = {} + for _ = 1, count do + local chunks = {} + for _ = 1, next_int(16) - 1 do + table.insert(chunks, chars[next_int(#chars)]) + end + table.insert(lines, table.concat(chunks)) + end + return lines +end + +---@param core table +---@param debug_name string +---@return any regex +local function action_regex(core, debug_name) + for _, trigger_action in ipairs(core.trigger_actions) do + if trigger_action.debug_name == debug_name then + return trigger_action.regex + end + end + error("no trigger action " .. debug_name) +end + +T["trailer_value_pattern_matches_the_lookbehind_pattern"] = function() + local core = Core.new({}) + -- nvim-cmp wraps the keyword pattern like this + local keyword = vim.regex([[\%(]] .. core.keyword_pattern .. [[\)\m$]]) + local lookbehind_keyword = + vim.regex([[\%(\%(^[[:alnum:]-]\+\|]] .. LOOKBEHIND_VALUE_PATTERN .. [[\|[:#@!]\S*\)\)\m$]]) + local value = action_regex(core, "git_trailer_values") + local lookbehind_value = vim.regex([[\%(]] .. LOOKBEHIND_VALUE_PATTERN .. [[\)$]]) + + local lines = { + "", + "Co-auth", + "Co-authored-by:", + "Co-authored-by: ", + "Co-authored-by:Ad", + "Co-authored-by: Ada Lo", + "Co-authored-by: Ada x", + "Co-authored-by: Ada @x", + "Fixes:", + "Fixes:abc", + "Fixes: #12", + "Fixes: 12 #", + "Fixes:\tvalue", + "Note: see #12", + "Note: thanks @ada", + "Note: see :abc", + "Key : value", + " Key: value", + "-: value", + "é-key: value", + "refs :abc", + "body text he", + } + vim.list_extend(lines, random_lines(3000)) + + for _, line in ipairs(lines) do + eq(keyword:match_str(line), lookbehind_keyword:match_str(line), "keyword offset for " .. vim.inspect(line)) + eq(value:match_str(line), lookbehind_value:match_str(line), "value edit start for " .. vim.inspect(line)) + end +end + +T["trailer_patterns_are_fast_on_long_lines"] = function() local core = Core.new({}) + local regexes = { vim.regex([[\%(]] .. core.keyword_pattern .. [[\)\m$]]) } + for _, trigger_action in ipairs(core.trigger_actions) do + table.insert(regexes, trigger_action.regex) + end + local line = string.rep("parser cache ", 400) + + local start = vim.uv.hrtime() + for _ = 1, 3 do + for _, regex in ipairs(regexes) do + regex:match_str(line) + end + end + local elapsed_ms = (vim.uv.hrtime() - start) / 1e6 + + -- The lookbehind took about 2 s here, and the `\zs` form takes about 1 ms + ok(elapsed_ms < 200, string.format("patterns take %.1f ms on a %d character line", elapsed_ms, #line)) +end + +T["trailer_routing"] = function() + local core = Core.new(shared_hosts()) local calls = {} local function record(name, handled) return function() @@ -144,12 +362,73 @@ T["trailer_routing"] = function() cursor = { row = 3, character = #case.line }, line = case.line, bufnr = bufnr, - }, {}) + }, shared_remote()) eq(table.concat(calls, ","), case.expected, "route for " .. vim.inspect(case.line)) end end +---Replace the regexes of the trigger actions with wrappers that count the matches. +---@param core table +---@return table counts By the name of the first action of each regex. +local function count_regex_matches(core) + local counts = {} + local wrappers = {} + for _, trigger_action in ipairs(core.trigger_actions) do + local regex = trigger_action.regex + if regex then + if not wrappers[regex] then + local name = trigger_action.debug_name + counts[name] = 0 + wrappers[regex] = { + match_str = function(_, line) + counts[name] = counts[name] + 1 + return regex:match_str(line) + end, + } + end + trigger_action.regex = wrappers[regex] + end + end + return counts +end + +T["routing_checks_availability_first_and_matches_each_regex_once"] = function() + local core = Core.new({}) + local counts = count_regex_matches(core) + local line = "Co-authored-by: Ad" + + local octo = helpers.buffer({ "title", "", line }, "octo") + eq(#core:_route({ cursor = { row = 3, character = #line }, line = line, bufnr = octo }), 0, "no trailers in octo") + eq(counts, { git_trailer_keys = 0, git_trailer_identities = 0 }, "unavailable actions match no regex") + + local bufnr = helpers.buffer({ "feat: subject", "", line }) + local context = { cursor = { row = 3, character = #line }, line = line, bufnr = bufnr } + local routes = core:_route(context) + eq(#routes, 2, "identities and values routes") + eq(routes[1].edit_start, 16, "identities edit start") + eq(routes[2].edit_start, 16, "values edit start") + eq(counts, { git_trailer_keys = 1, git_trailer_identities = 1 }, "the value actions share one match") + + helpers.stub(repository, "is_git_repo", function(on_result) + on_result(true) + end) + helpers.stub(repository, "discover", function(_, opts) + opts.on_complete({}) + end) + core.sources.git = { + get_trailer_identities = function() + return true + end, + may_have_trailer_values = function() + return true + end, + } + counts.git_trailer_keys, counts.git_trailer_identities = 0, 0 + core:complete(context, function() end) + eq(counts, { git_trailer_keys = 1, git_trailer_identities = 1 }, "a request matches each regex once") +end + T["core_pre_route_skips_repository"] = function() local checks = 0 helpers.stub(repository, "is_git_repo", function() @@ -180,6 +459,179 @@ T["core_pre_route_skips_repository"] = function() eq(checks, 1, "matched request checks the repository") end +---A request for a trigger character on line 1 of a scratch buffer. +---@param trigger string +---@return table context +local function trigger_context(trigger) + local line = "see " .. trigger + return { + trigger_character = trigger, + cursor = { row = 1, character = #line }, + line = line, + bufnr = helpers.buffer({ line }), + } +end + +---@param git_info cmp_git.GitInfo +local function stub_repository(git_info) + helpers.stub(repository, "is_git_repo", function(on_result) + on_result(true) + end) + helpers.stub(repository, "discover", function(_, opts) + opts.on_complete(git_info) + end) +end + +---@param lists cmp_git.CompletionList[] +---@param reason string +local function expect_one_empty_list(lists, reason) + eq(#lists, 1, reason .. ": one callback") + eq(#lists[1].items, 0, reason .. ": no items") + eq(lists[1].isIncomplete, false, reason .. ": final list") +end + +T["complete_answers_when_no_action_handles_the_request"] = function() + helpers.stub(repository, "is_git_repo", function(on_result) + on_result(false) + end) + local core = Core.new({}) + local callback, lists = helpers.recorder() + core:complete(trigger_context("#"), callback) + expect_one_empty_list(lists, "outside a git repository") + + -- Neither GitHub nor GitLab handles these hosts, so the sources decline without a process + for _, git_info in ipairs({ { host = "gitea.com", owner = "owner", repo = "repo" }, {} }) do + stub_repository(git_info) + for _, trigger in ipairs({ "#", "@", "!" }) do + callback, lists = helpers.recorder() + core:complete(trigger_context(trigger), callback) + expect_one_empty_list(lists, string.format("%s with host %s", trigger, git_info.host)) + end + end + + local unknown = Core.new({ trigger_actions = { { trigger_character = "#", actions = { "no_such_action" } } } }) + callback, lists = helpers.recorder() + unknown:complete(trigger_context("#"), callback) + expect_one_empty_list(lists, "unknown action") +end + +T["complete_does_not_answer_after_cancel"] = function() + local pending + helpers.stub(repository, "is_git_repo", function(on_result) + pending = on_result + end) + local core = Core.new({}) + local callback, lists = helpers.recorder() + + local controller = core:complete(trigger_context("#"), callback) + controller:cancel() + pending(false) + + eq(#lists, 0, "cancelled request does not call back") +end + +---Stub the repository checks and the sources, and record the calls. +---@param core table +---@param is_git_repo boolean +---@return string[] calls +local function record_calls(core, is_git_repo) + local calls = {} + helpers.stub(repository, "is_git_repo", function(on_result) + table.insert(calls, "is_git_repo") + on_result(is_git_repo) + end) + helpers.stub(repository, "discover", function(_, opts) + table.insert(calls, "discover") + opts.on_complete({ host = "github.com", owner = "owner", repo = "repo" }) + end) + + local function record(name, handled) + return function() + table.insert(calls, name) + return handled + end + end + core.sources.git = { + get_commits = record("commits", true), + get_trailer_identities = record("identities", true), + may_have_trailer_values = function() + return true + end, + } + core.sources.gitlab = { + complete_issues = record("gitlab_issues", false), + complete_mentions = record("gitlab_mentions", false), + } + core.sources.github = { + complete_issues = record("github_issues", true), + complete_issues_and_change_requests = record("github_issues", true), + complete_mentions = record("github_mentions", true), + } + return calls +end + +T["complete_discovers_the_remote_only_for_actions_that_need_it"] = function() + local core = Core.new({}) + local calls = record_calls(core, true) + local function run(context) + for key in pairs(calls) do + calls[key] = nil + end + core:complete(context, function() end) + return table.concat(calls, ",") + end + + eq(run(trigger_context(":")), "is_git_repo,commits", "commits need no remote") + eq(run(trigger_context("#")), "is_git_repo,discover,github_issues", "issues need the remote") + + -- The identities action handles the request, so the mention actions after it never run + local line = "Co-authored-by: Ada last, "range with only spaces is empty") +end + +T["trim_takes_linear_time"] = function() + -- The old pattern took about 3 s for each of these strings + local run = string.rep(" ", 32000) + local start = os.clock() + eq(utils.trim("a" .. run .. "b"), "a" .. run .. "b", "inner spaces are kept") + eq(utils.trim(run), "", "only spaces") + eq(utils.trim(run .. "a" .. run), "a", "spaces around one character") + ok(os.clock() - start < 0.25, "trim of long runs of spaces is fast") +end + T["split_nul"] = function() eq(utils.split_nul("one\0\0two three\0"), { "one", "", "two three" }, "keeps empty records") eq(utils.split_nul("one\0dangling"), { "one" }, "drops a trailing record without a NUL")