From 87c03ef6c92d816cc7486f06f356c1d16603a63f Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Mon, 2 Nov 2026 22:01:46 +1100 Subject: [PATCH] feat: complete the autosquash prefixes and the commit titles On line 1 of a commit message, cmp-git now completes the subjects that `git rebase --autosquash` reads: - The prefix words: `fi`, `sq`, and `am` give `fixup!`, `squash!`, and `amend!`. `git.autosquash.prefixes` sets the words and the minimum number of letters (default 2). After a prefix item, the menu opens again with the commit titles. - The commit titles after `fixup!`, `squash!`, and `amend!`, newest first, with one item for each title. The list hides the titles that start with an autosquash prefix. It uses the commit cache and the `git.commits` config, so a cached history starts no new git process. The edit starts after the prefix and its spaces, so a `:`, `#`, `@`, or `!` in the title starts no other completion. The completion works only on line 1, in the filetypes of `git.autosquash.filetypes` (`gitcommit` and `NeogitCommitMessage`). Two new default entries come before the trailer entries: `git_autosquash_prefixes` shares the regex of the trailer keys, and `git_autosquash` has its own pattern and keyword pattern. Core loads only the line checks, which run before the reads of the buffer. Only the git source loads the autosquash capability. The menu reopen uses the `execute` hook of each engine: - nvim-cmp `2ffe79f`: `source.execute(self, item, callback)` calls `self.source:execute(item, callback)` after the insert. cmp-git calls `callback`, then `cmp.complete()` with only the `git` source. - blink.cmp `v1.10.0-258-geece1c5`: `module:execute(context, item, resolve, default_implementation)`. cmp-git calls `default_implementation` and `resolve`, then `blink.show()` with the provider of the item. A test runs `git rebase -i --autosquash` on completed subjects and checks that each commit comes right after its target with `fixup`, `squash`, or `fixup -C`. The keystroke benchmarks get an autosquash line and a declined line 1 request. Fixes #42 --- README.md | 39 ++- benchmarks/cases/keystroke.lua | 12 + docs/configuration.md | 121 +++++++- lua/cmp_git/blink.lua | 24 ++ lua/cmp_git/config.lua | 45 +++ lua/cmp_git/core.lua | 24 ++ lua/cmp_git/format.lua | 84 +++++- .../provider/capabilities/autosquash.lua | 96 ++++++ .../capabilities/autosquash_lines.lua | 64 ++++ lua/cmp_git/provider/capabilities/commits.lua | 12 +- .../provider/capabilities/trailer_lines.lua | 5 +- lua/cmp_git/sort.lua | 20 +- lua/cmp_git/source.lua | 19 ++ lua/cmp_git/sources/git.lua | 23 ++ .../provider/capabilities/test_autosquash.lua | 132 +++++++++ .../capabilities/test_autosquash_lines.lua | 73 +++++ tests/provider/capabilities/test_commits.lua | 59 ++++ tests/sources/test_git.lua | 159 ++++++++++ tests/test_blink.lua | 103 +++++++ tests/test_config.lua | 82 +++++- tests/test_core.lua | 273 +++++++++++++++++- tests/test_format.lua | 61 ++++ tests/test_sort.lua | 9 + tests/test_source.lua | 88 ++++++ 24 files changed, 1575 insertions(+), 52 deletions(-) create mode 100644 lua/cmp_git/provider/capabilities/autosquash.lua create mode 100644 lua/cmp_git/provider/capabilities/autosquash_lines.lua create mode 100644 tests/provider/capabilities/test_autosquash.lua create mode 100644 tests/provider/capabilities/test_autosquash_lines.lua diff --git a/README.md b/README.md index 0e16fa6..e5e436f 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,14 @@ Git source for [hrsh7th/nvim-cmp](https://github.com/hrsh7th/nvim-cmp) and [Sagh ## Features -| Git | Trigger | -| ------------------ | ------------------------------------------------------------------------ | -| Commits | : | -| Trailer keys | A word at the start of a line in a commit message | -| Trailer identities | `Name ` after an identity key, such as `Co-authored-by: ` | -| Trailer values | An earlier value of the key after any other key, such as `Assisted-by: ` | +| Git | Trigger | +| ------------------- | ------------------------------------------------------------------------ | +| Commits | : | +| Trailer keys | A word at the start of a line in a commit message | +| Trailer identities | `Name ` after an identity key, such as `Co-authored-by: ` | +| Trailer values | An earlier value of the key after any other key, such as `Assisted-by: ` | +| Autosquash prefixes | The start of `fixup`, `squash`, or `amend` on line 1 of a commit message | +| Autosquash titles | `fixup!`, `squash!`, or `amend!` on line 1 of a commit message | | GitHub | Trigger | | ------------- | ------- | @@ -197,6 +199,21 @@ require("cmp_git").setup({ format = format.git.trailers.values, }, }, + autosquash = { + filetypes = { "gitcommit", "NeogitCommitMessage" }, + -- the prefix words at the start of line 1, such as `fixup! ` for `fi` + prefixes = { + words = { "fixup", "squash", "amend" }, + min_length = 2, -- letters to type before the words show + sort_by = sort.git.autosquash.prefixes, + format = format.git.autosquash.prefixes, + }, + -- the titles of earlier commits after the prefix, from the commits of `git.commits` + commits = { + sort_by = sort.git.autosquash.commits, + format = format.git.autosquash.commits, + }, + }, }, github = { hosts = {}, -- list of private instances of github @@ -282,6 +299,15 @@ require("cmp_git").setup({ }, }, trigger_actions = { + { + pattern = [[^[[:alnum:]-]\+]], + actions = { "git_autosquash_prefixes" }, + }, + { + pattern = [[^\%(fixup\|squash\|amend\)!\s*\zs.*]], + keyword_pattern = [[^\%(fixup\|squash\|amend\)\%(\zs!\S*\|!\s*\zs.*\)]], + actions = { "git_autosquash" }, + }, { pattern = [[^[[:alnum:]-]\+]], actions = { "git_trailer_keys" }, @@ -321,6 +347,7 @@ require("cmp_git").setup({ - [Trigger actions](docs/configuration.md#trigger-actions) - [Trailer completion](docs/configuration.md#trailer-completion) +- [Autosquash completion](docs/configuration.md#autosquash-completion) - [Format and sort items](docs/configuration.md#format-and-sort-items) - [Filetypes](docs/configuration.md#filetypes) - [Remotes and SSH aliases](docs/configuration.md#remotes-and-ssh-aliases) diff --git a/benchmarks/cases/keystroke.lua b/benchmarks/cases/keystroke.lua index 5912f6a..5b566d3 100644 --- a/benchmarks/cases/keystroke.lua +++ b/benchmarks/cases/keystroke.lua @@ -59,6 +59,7 @@ return { keyword_offset("prose_300", fixtures.prose(300)), keyword_offset("prose_1000", fixtures.prose(1000)), keyword_offset("trailer_value", "Co-authored-by: Ada Lov"), + keyword_offset("autosquash", "fixup! feat: add the par"), { -- The first word of the subject matches the trailer key pattern, but trailers start below the subject name = "keystroke/nvim_cmp/complete_declined", @@ -70,6 +71,17 @@ return { state.source:complete(state.params, noop) end, }, + { + -- Lowercase letters on the subject line that start no autosquash prefix word, so each word is compared + name = "keystroke/nvim_cmp/complete_declined_letters", + setup = function() + fixtures.scratch_buffer(LINES) + return { source = require("cmp_git.source").new({}), params = cmp_params("refactor", 1) } + end, + run = function(state) + state.source:complete(state.params, noop) + end, + }, { name = "keystroke/nvim_cmp/complete_declined_prose_300", setup = function() diff --git a/docs/configuration.md b/docs/configuration.md index aa3ada1..60cc0ca 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -32,12 +32,16 @@ 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. +The default autosquash entries come first. As a result, on line 1 after `fixup!`, `squash!`, or `amend!`, the `:`, +`#`, `@`, and `!` triggers complete commit titles. For example, `fixup!` completes commit titles, not GitLab merge +requests, and `fixup! feat:` completes commit titles, not commits. + ### Built-in actions The built-in actions are: -- `git_commits`, `git_trailer_keys`, `git_trailer_identities`, and `git_trailer_values`. These actions need a - `pattern` entry, except `git_commits`. +- `git_commits`, `git_trailer_keys`, `git_trailer_identities`, `git_trailer_values`, `git_autosquash_prefixes`, and + `git_autosquash`. These actions need a `pattern` entry, except `git_commits`. - `gitlab_issues`, `gitlab_mentions`, and `gitlab_change_requests`. - `github_issues`, `github_change_requests`, `github_issues_and_change_requests`, and `github_mentions`. - `gitea_issues`, `gitea_change_requests`, `gitea_issues_and_change_requests`, and `gitea_mentions`. @@ -96,6 +100,114 @@ To turn off trailer completion, set `trigger_actions` without the two `pattern` If you set your own `trigger_actions`, add the two `pattern` entries from the [defaults](../README.md#config) to get trailer completion. +## Autosquash completion + +On the subject line of a commit message, cmp-git completes the subjects of `fixup!`, `squash!`, and `amend!` commits. +`git rebase --autosquash` moves each of these commits after its target commit. + +| You type | Completion | From | +| ------------ | -------------------------- | ----------------------------------------- | +| `fi` | `fixup! ` | A word in `git.autosquash.prefixes.words` | +| `fixup!` | `fixup! feat: add parser` | The title of an earlier commit | +| `squash! fe` | `squash! feat: add parser` | The title of an earlier commit | +| `amend!` | `amend! fix: handle input` | The title of an earlier commit | + +After you select a prefix word, the completion menu opens again and shows the commit titles. This works with +`nvim-cmp` and with `blink.cmp`. + +Autosquash completion works only when all of these conditions are true: + +- The filetype of the buffer is in `git.autosquash.filetypes`. This list does not support `"*"`. Autosquash completion + also needs the filetype in `filetypes`. +- The cursor is on line 1. + +The prefix words also need these conditions: + +- The text before the cursor has only letters, and it is the start of a word in `git.autosquash.prefixes.words`. The + match is case-sensitive, because git reads only lowercase prefixes. +- The text has at least `git.autosquash.prefixes.min_length` letters (default 2). A value below 1 acts as 1. With fewer + letters, cmp-git gives an incomplete list without items, so the completion engine asks again on the next letter. + +If the text starts no word, the request goes to the next entries. For example, `fe` gives no prefix word. + +The commit titles come from the last `git.commits.limit` commits, the same commits that `:` completes. Thus a commit +title request after a `:` request in the same buffer starts no git process. The list has these properties: + +- It has no commit whose title starts with `fixup! `, `squash! `, or `amend! `. +- It has one item for each title. The item is the newest commit with that title. +- It can show commits that you pushed, and commits outside the range of your rebase. + +After the prefix and its spaces, `:`, `#`, `@`, and `!` start no other completion. Only the first prefix counts, so in +`squash! fixup! x`, the commit title replaces `fixup! x`. + +When the typed text touches the `!`, as in `fixup!` or `fixup!feat`, `trigger_char` of `git.autosquash.commits.format` +is `!`. The default `insertText` then starts with a space, and the default `filterText` starts with `!`. After a +space, `trigger_char` is `nil`. + +### How git finds the target commit + +`git rebase --autosquash` reads the text after the prefix. It does not read the body of the commit. It finds the +target commit in this order: + +1. A commit with exactly this title. +2. A commit that the text names, such as a hash or `HEAD~2`. Git does this step only if the text has no space. +3. A commit whose title starts with the text. + +Git skips a repeated prefix, so `fixup! fixup! feat: add parser` targets `feat: add parser`. + +If two commits in the range of the rebase have the same title, git targets the oldest one. `git commit --fixup` has the +same behavior. cmp-git inserts the title, so git can target an older commit than the commit that you selected. To insert +the hash in place of the title, change `insertText`. For example: + +```lua +require("cmp_git").setup({ + git = { + autosquash = { + commits = { + format = { + insertText = function(trigger_char, commit) + return (trigger_char and " " or "") .. commit.sha + end, + }, + }, + }, + }, +}) +``` + +### Prefix words and the commit titles + +Two settings name the prefixes: + +- `git.autosquash.prefixes.words` sets the prefix words. +- The `pattern` of the `git_autosquash` entry sets the prefixes after which the commit titles complete. + +If a word is not in the pattern, its item inserts the word, `!`, and a space, but the commit titles do not show. +The commit titles show only when line 1 starts with letters and `!`, so each prefix of the pattern must have this +form. `git rebase --autosquash` reads only `fixup!`, `squash!`, and `amend!`. + +### Turn off autosquash completion + +- To turn off all autosquash completion, set `git.autosquash.filetypes = {}`. +- To turn off the prefix words, set `git.autosquash.prefixes.words = {}`, or set `trigger_actions` without the entry + with the `git_autosquash_prefixes` action. +- To turn off the commit titles, set `trigger_actions` without the entry with the `git_autosquash` action. + +If you set your own `trigger_actions`, add these two entries to get autosquash completion. Put them before the other +entries, so that the `:`, `#`, `@`, and `!` entries do not handle the request first: + +```lua +{ + pattern = [[^[[:alnum:]-]\+]], + actions = { "git_autosquash_prefixes" }, +}, +{ + pattern = [[^\%(fixup\|squash\|amend\)!\s*\zs.*]], + keyword_pattern = [[^\%(fixup\|squash\|amend\)\%(\zs!\S*\|!\s*\zs.*\)]], + actions = { "git_autosquash" }, +}, +``` + ## Format and sort items Each capability, such as `git.commits` or `github.issues`, has a `format` table and a `sort_by` value. @@ -144,6 +256,8 @@ For compatibility, cmp-git also supports `filter_fn` on a provider, as an alias | Capability | Fields | | ---------------------------------------- | --------------------------------------------------------------------------------------- | | `git.commits` | `sha`, `title`, `description`, `author_name`, `author_mail`, `commit_timestamp`, `diff` | +| `git.autosquash.prefixes` | `word`, `index` | +| `git.autosquash.commits` | The same fields as `git.commits` | | `git.trailers.keys` | `key`, `count`, `builtin_index` | | `git.trailers.identities` | `name`, `email`, `source`, `is_self`, `timestamp`, `diff` | | `git.trailers.values` | `key`, `value`, `count`, `timestamp`, `diff` | @@ -167,6 +281,7 @@ For compatibility, cmp-git also supports `filter_fn` on a provider, as an alias - `diff` is the number of seconds since the commit, or since the most recent use of the identity or the value. - `count` is the number of uses in the scanned commits. - `builtin_index` is the position of the key in `git.trailers.keys.builtin`. It is `nil` for a key from history. +- `word` is a word of `git.autosquash.prefixes.words`, and `index` is its position in that list. - `source` is `"author"`, the trailer key, or `"self"`. `is_self` is `true` for your git identity. ### Sort @@ -177,6 +292,8 @@ field name, such as `"title"`. For a field name, cmp-git uses the value of that | Capability | Default order | | ----------------------------------------- | -------------------------------------------------------------------------- | | `git.commits` | Newest first | +| `git.autosquash.prefixes` | The order of `words` | +| `git.autosquash.commits` | Newest first | | `git.trailers.keys` | Most used first, then the order of `keys.builtin`, then alphabetical order | | `git.trailers.identities` | Most recent first. Your git identity comes first if it is not in history. | | `git.trailers.values` | Most used first, then most recent | diff --git a/lua/cmp_git/blink.lua b/lua/cmp_git/blink.lua index 923c0a9..aa77f8d 100644 --- a/lua/cmp_git/blink.lua +++ b/lua/cmp_git/blink.lua @@ -50,6 +50,30 @@ local function cursor_from_context(ctx) return { row = position[1], character = position[2] } end +---@class cmp_git.BlinkCompletionItem: cmp_git.CompletionItem +---@field source_id string The id of the blink.cmp provider of the item. + +---blink.cmp calls this to accept an item. `default_implementation` inserts the text. +---@param item cmp_git.BlinkCompletionItem +---@param callback fun() +---@param default_implementation fun() +function Blink:execute(_ctx, item, callback, default_implementation) + default_implementation() + callback() + if not Core.reopens_menu(item) then + return + end + + -- After blink.cmp has finished the accept + vim.schedule(function() + -- Only blink.cmp calls this, but the module can be missing for other callers + local ok, blink = pcall(require, "blink.cmp") + if ok then + blink.show({ providers = { item.source_id } }) + end + end) +end + function Blink:get_completions(ctx, callback) local cancelled = false local responded = false diff --git a/lua/cmp_git/config.lua b/lua/cmp_git/config.lua index 44a3e98..56282c6 100644 --- a/lua/cmp_git/config.lua +++ b/lua/cmp_git/config.lua @@ -31,6 +31,16 @@ local TRAILER_VALUE_KEYWORD_PATTERN = [[^[[:alnum:]-]\+\zs:\S*\|]] .. TRAILER_VA -- 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 +-- Keep these in sync with the autosquash entry in the README. `git rebase --autosquash` recognizes only these three +-- prefixes. `\zs` sets the edit start after the prefix and its spaces, so that `:`, `#`, `@`, and `!` in the title +-- start no other completion. Only the first prefix counts, so an item replaces `fixup! x` in `squash! fixup! x`. +local AUTOSQUASH_PATTERN = [[^\%(fixup\|squash\|amend\)!\s*\zs.*]] +-- As for `Key:` in `TRAILER_VALUE_KEYWORD_PATTERN`, the first branch starts the keyword at the `!` when the typed text +-- touches it (`fixup!` or `fixup!feat`). The edit then starts after the `!` (`AUTOSQUASH_PATTERN`), and the commit +-- format adds a space before the title and filters on the `!`. The second branch is `AUTOSQUASH_PATTERN`. The branches +-- share one `^`, because Vim tries each `^` branch of the keyword pattern at each column of the line. +local AUTOSQUASH_KEYWORD_PATTERN = [[^\%(fixup\|squash\|amend\)\%(\zs!\S*\|!\s*\zs.*\)]] + local IDENTITY_TRAILER_KEYS = { "Co-authored-by", "Signed-off-by", @@ -102,6 +112,30 @@ local defaults = { format = format.git.trailers.values, }, }, + ---Completion on the subject line (line 1) of `fixup!`, `squash!`, and `amend!` commits + ---@class cmp_git.Config.GitAutosquash + autosquash = { + ---@type string[] + filetypes = { "gitcommit", "NeogitCommitMessage" }, + ---The prefix words at the start of the line, such as `fixup!` for `fi`. The commit list opens only after a + ---prefix that the pattern of the `git_autosquash` entry matches. + ---@class cmp_git.Config.GitAutosquashPrefixes + prefixes = { + ---@type string[] + words = { "fixup", "squash", "amend" }, + ---Letters to type before the words show. A value below 1 acts as 1. + ---@type integer + min_length = 2, + sort_by = sort.git.autosquash.prefixes, + format = format.git.autosquash.prefixes, + }, + ---The titles of earlier commits after the prefix. The history and its cache are the ones of `commits`. + ---@class cmp_git.Config.GitAutosquashCommits + commits = { + sort_by = sort.git.autosquash.commits, + format = format.git.autosquash.commits, + }, + }, }, ---@class cmp_git.Config.GitHub ---@field filter_fn? fun(trigger_char: string, item: cmp_git.GitHub.Issue | cmp_git.GitHub.Mention | cmp_git.GitHub.PullRequest): string Compatibility alias for all GitHub capability format.filterText functions. @@ -215,6 +249,17 @@ local defaults = { }, ---@type (cmp_git.Config.NamedTriggerActions|cmp_git.Config.PatternTriggerActions|cmp_git.Config.TriggerAction)[] trigger_actions = { + { + -- The pattern of the trailer keys, so that core matches one regex for both entries, and the keyword + -- pattern does not grow. The `available` check of the action tests the line. + pattern = TRAILER_KEY_PATTERN, + actions = { "git_autosquash_prefixes" }, + }, + { + pattern = AUTOSQUASH_PATTERN, + keyword_pattern = AUTOSQUASH_KEYWORD_PATTERN, + actions = { "git_autosquash" }, + }, { pattern = TRAILER_KEY_PATTERN, actions = { "git_trailer_keys" }, diff --git a/lua/cmp_git/core.lua b/lua/cmp_git/core.lua index 34477ae..d7aa7fb 100644 --- a/lua/cmp_git/core.lua +++ b/lua/cmp_git/core.lua @@ -1,6 +1,7 @@ -- 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 autosquash_lines = require("cmp_git.provider.capabilities.autosquash_lines") local log = require("cmp_git.log") local trailer_lines = require("cmp_git.provider.capabilities.trailer_lines") @@ -53,6 +54,12 @@ 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)) end, + git_autosquash_prefixes = function(core, callback, context) + return route_status(core.sources.git:get_autosquash_prefixes(callback, context)) + end, + git_autosquash = function(core, callback, context) + return route_status(core.sources.git:get_autosquash_commits(callback, context)) + end, git_trailer_keys = function(core, callback, context, _git_info, trigger_char) return route_status(core.sources.git:get_trailer_keys(callback, context, trigger_char)) end, @@ -84,6 +91,8 @@ local builtin_trigger_actions = { -- 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_autosquash_prefixes = true, + git_autosquash = true, git_trailer_keys = true, git_trailer_identities = true, git_trailer_values = true, @@ -91,6 +100,12 @@ local LOCAL_ACTIONS = { -- Synchronous checks that run before repository discovery, so that a declined request starts no git process. local builtin_trigger_availability = { + git_autosquash_prefixes = function(core, context) + return autosquash_lines.available(core.config.git.autosquash, context, "prefixes") + end, + git_autosquash = function(core, context) + return autosquash_lines.available(core.config.git.autosquash, context, "commits") + end, git_trailer_keys = function(core, context) return trailer_lines.available(core.config.git.trailers, context, "keys") end, @@ -557,6 +572,15 @@ function Core:complete(context, callback) return controller end +---Whether the completion engine must open the menu again after the insert of the item. A prefix word item has this +---marker in `data`, so that the commit list shows at once after `fixup! `. +---@param item? { data?: any } +---@return boolean +function Core.reopens_menu(item) + local data = item and item.data + return type(data) == "table" and data.reopen == true +end + function Core:get_keyword_pattern() return self.keyword_pattern end diff --git a/lua/cmp_git/format.lua b/lua/cmp_git/format.lua index 1b659f7..035970a 100644 --- a/lua/cmp_git/format.lua +++ b/lua/cmp_git/format.lua @@ -76,6 +76,29 @@ local function gitea_formats() } end +---@param commit cmp_git.Commit +---@return lsp.MarkupContent +local function commit_documentation(_trigger_char, commit) + return { + kind = "markdown", + value = string.format( + "# %s\n\n%s\n\nAuthored by %s (%s), committed on %s", + commit.title, + commit.description, + commit.author_name, + commit.author_mail, + os.date("%c", commit.commit_timestamp) + ), + } +end + +-- What `git rebase --autosquash` does with a commit whose title starts with the prefix +local AUTOSQUASH_ACTIONS = { + fixup = "keeps the message of that commit", + squash = "combines the two messages", + amend = "replaces the message of that commit with the message of this commit", +} + local M = { git = { ---@type cmp_git.FormatConfig @@ -90,19 +113,54 @@ local M = { insertText = function(_trigger_char, commit) return commit.sha end, - documentation = function(_trigger_char, commit) - return { - kind = "markdown", - value = string.format( - "# %s\n\n%s\n\nAuthored by %s (%s), committed on %s", - commit.title, - commit.description, - commit.author_name, - commit.author_mail, - os.date("%c", commit.commit_timestamp) - ), - } - end, + documentation = commit_documentation, + }, + autosquash = { + ---@type cmp_git.FormatConfig + prefixes = { + label = function(_trigger_char, prefix) + return prefix.word .. "!" + end, + filterText = function(_trigger_char, prefix) + return prefix.word + end, + insertText = function(_trigger_char, prefix) + return prefix.word .. "! " + end, + documentation = function(_trigger_char, prefix) + local action = AUTOSQUASH_ACTIONS[prefix.word] + local text = "`git rebase --autosquash` reads only the prefixes `fixup!`, `squash!`, and `amend!`." + if action then + text = string.format( + "`git rebase --autosquash` adds the changes of this commit to the commit with the title " + .. "after the prefix, and %s.", + action + ) + end + return { + kind = "markdown", + value = string.format("# %s!\n\n%s", prefix.word, text), + } + end, + }, + -- trigger_char is `!` only when the typed text touches the `!` of the prefix (`fixup!` or `fixup!feat`). + -- Then the nvim-cmp offset starts at the `!`, and the inserted title needs a space before it. + ---@type cmp_git.FormatConfig + commits = { + label = function(_trigger_char, commit) + return commit.title + end, + filterText = function(trigger_char, commit) + if trigger_char then + return string.format("%s %s", trigger_char, commit.title) + end + return commit.title + end, + insertText = function(trigger_char, commit) + return (trigger_char and " " or "") .. commit.title + end, + documentation = commit_documentation, + }, }, trailers = { ---@type cmp_git.FormatConfig diff --git a/lua/cmp_git/provider/capabilities/autosquash.lua b/lua/cmp_git/provider/capabilities/autosquash.lua new file mode 100644 index 0000000..410083e --- /dev/null +++ b/lua/cmp_git/provider/capabilities/autosquash.lua @@ -0,0 +1,96 @@ +local autosquash_lines = require("cmp_git.provider.capabilities.autosquash_lines") +local commits = require("cmp_git.provider.capabilities.commits") +local format = require("cmp_git.format") + +local M = { + prefixes = {}, + commits = {}, +} + +-- The prefixes that `git rebase --autosquash` reads. Git hard-codes them, so the filter does not follow the config. +local AUTOSQUASH_PREFIXES = { "fixup! ", "squash! ", "amend! " } + +---@param title string +---@return boolean +local function is_autosquash_title(title) + for _, prefix in ipairs(AUTOSQUASH_PREFIXES) do + if string.sub(title, 1, #prefix) == prefix then + return true + end + end + return false +end + +---The items of the prefix words that start with the text before the cursor. The line check has tested the text. +---@param config cmp_git.Config.GitAutosquashPrefixes +---@param line string +---@return cmp_git.CompletionList +function M.prefixes.list(config, line) + -- An incomplete list makes the completion engine ask again on the next letter + if #line < math.max(config.min_length or 1, 1) then + return { items = {}, isIncomplete = true } + end + + local prefixes = {} + for index, word in ipairs(config.words or {}) do + if string.sub(word, 1, #line) == line then + ---@class cmp_git.AutosquashPrefix + local prefix = { + word = word, + ---The position of the word in `words` + index = index, + ---The adapters open the menu again after the insert, so that the commit list shows at once + reopen = true, + } + prefixes[#prefixes + 1] = prefix + end + end + + return { items = format.items(config, nil, prefixes), isIncomplete = false } +end + +---The items of the commits that an autosquash commit can target: one for each title, without the autosquash commits. +---@param history cmp_git.Commit[] Newest first, so that a repeated title keeps its newest commit. +---@param config cmp_git.Config.GitAutosquashCommits +---@param trigger_char string? +---@return cmp_git.CompletionItem[] +function M.commits.items(history, config, trigger_char) + local items = {} + local seen = {} + for _, commit in ipairs(history) do + local title = commit.title + if not seen[title] and not is_autosquash_title(title) then + seen[title] = true + items[#items + 1] = format.item(config, trigger_char, commit) + end + end + return items +end + +---@class cmp_git.Provider.AutosquashCommitArgs +---@field cache table The cache of the `git.commits` history. +---@field callback fun(list: cmp_git.CompletionList) +---@field commits_config cmp_git.Config.GitCommits The request, the parse, and the cache of the history. +---@field config cmp_git.Config.GitAutosquashCommits The items. +---@field line? string The text before the cursor. +---@field runner? table +---@field bufnr? integer + +---@param args cmp_git.Provider.AutosquashCommitArgs +---@return cmp_git.SystemJob? +function M.commits.complete(args) + local config = args.config + return commits.complete({ + cache = args.cache, + callback = args.callback, + config = args.commits_config, + trigger_char = autosquash_lines.attached_trigger(args.line), + items = function(history, _commits_config, trigger_char) + return M.commits.items(history, config, trigger_char) + end, + runner = args.runner, + bufnr = args.bufnr, + }) +end + +return M diff --git a/lua/cmp_git/provider/capabilities/autosquash_lines.lua b/lua/cmp_git/provider/capabilities/autosquash_lines.lua new file mode 100644 index 0000000..a020775 --- /dev/null +++ b/lua/cmp_git/provider/capabilities/autosquash_lines.lua @@ -0,0 +1,64 @@ +---The checks of the line at the cursor for autosquash completion. Core runs them on each request, and they are apart +---from the autosquash capability, so that a request that autosquash completion declines does not load it. +local trailer_lines = require("cmp_git.provider.capabilities.trailer_lines") + +local M = {} + +---The trigger character to give to the commit format: `!` when the typed text touches the `!` of the prefix +---(`fixup!` or `fixup!feat`), else nil. +---@param line string? +---@return string? +function M.attached_trigger(line) + return line and string.match(line, "^%a+(!)%S*$") +end + +---@param words string[] +---@param text string +---@return boolean +local function starts_a_word(words, text) + for _, word in ipairs(words) do + if string.sub(word, 1, #text) == text then + return true + end + end + return false +end + +---Autosquash completion is only offered on the subject line (line 1) of a commit message. The prefix words also need +---only letters before the cursor, and the letters must start a word. Git reads only lowercase prefixes, so the words +---match case-sensitively. The commit list needs a line that starts with letters and `!`. +---@param config cmp_git.Config.GitAutosquash +---@param context { bufnr?: integer, cursor?: { row?: integer }, line?: string } +---@param kind "prefixes" | "commits" +---@return boolean +function M.available(config, context, kind) + if not context.cursor or context.cursor.row ~= 1 then + return false + end + + -- Core asks on each request on line 1, so the checks of the line come before the reads of the buffer + local line = context.line + if not line then + return false + end + if kind == "prefixes" then + if not string.find(line, "^%a+$") or not starts_a_word(config.prefixes.words or {}, line) then + return false + end + elseif not string.find(line, "^%a+!") then + -- The commit list follows a prefix of letters and `!`, as git reads it and as `M.attached_trigger` assumes. + -- The pattern of the entry chooses the prefixes. + return false + 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 }) + return trailer_lines.filetype_matches(config.filetypes or {}, filetype) +end + +return M diff --git a/lua/cmp_git/provider/capabilities/commits.lua b/lua/cmp_git/provider/capabilities/commits.lua index 57e8567..8d00f64 100644 --- a/lua/cmp_git/provider/capabilities/commits.lua +++ b/lua/cmp_git/provider/capabilities/commits.lua @@ -139,7 +139,7 @@ end ---@param commits cmp_git.Commit[] ---@param config cmp_git.Config.GitCommits ----@param trigger_char string +---@param trigger_char string? ---@return cmp_git.CompletionItem[] function M.items(commits, config, trigger_char) local items = {} @@ -152,8 +152,9 @@ end ---@class cmp_git.Provider.CommitCapabilityArgs ---@field cache table ---@field callback fun(list: cmp_git.CompletionList) ----@field config cmp_git.Config.GitCommits ----@field trigger_char string +---@field config cmp_git.Config.GitCommits The request, the parse, and the cache of the history. +---@field trigger_char? string +---@field items? fun(commits: cmp_git.Commit[], config: cmp_git.Config.GitCommits, trigger_char: string?): cmp_git.CompletionItem[] Makes the items of the history, by default `M.items`. ---@field runner? table ---@field bufnr? integer @@ -161,9 +162,10 @@ end ---@return cmp_git.SystemJob? function M.job(args) local bufnr = args.bufnr or vim.api.nvim_get_current_buf() + local items = args.items or M.items if args.cache[bufnr] then - args.callback({ items = M.items(args.cache[bufnr], args.config, args.trigger_char), isIncomplete = false }) + args.callback({ items = items(args.cache[bufnr], args.config, args.trigger_char), isIncomplete = false }) return nil end @@ -184,7 +186,7 @@ function M.job(args) local parsed = M.parse(result, args.config) args.cache[bufnr] = parsed - args.callback({ items = M.items(parsed, args.config, args.trigger_char), isIncomplete = false }) + args.callback({ items = items(parsed, args.config, args.trigger_char), isIncomplete = false }) end) if not job then diff --git a/lua/cmp_git/provider/capabilities/trailer_lines.lua b/lua/cmp_git/provider/capabilities/trailer_lines.lua index b221301..e87f79e 100644 --- a/lua/cmp_git/provider/capabilities/trailer_lines.lua +++ b/lua/cmp_git/provider/capabilities/trailer_lines.lua @@ -2,10 +2,11 @@ ---the trailer capability, so that a request that trailer completion declines does not load the log parsers. local M = {} +---Whether the filetype, or one part of a compound filetype such as `gitcommit.markdown`, is in the list. ---@param filetypes string[] ---@param filetype string ---@return boolean -local function filetype_matches(filetypes, filetype) +function M.filetype_matches(filetypes, filetype) if vim.tbl_contains(filetypes, filetype) then return true end @@ -78,7 +79,7 @@ function M.available(config, context, kind) -- `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 + if not M.filetype_matches(config.filetypes or {}, filetype) then return false end diff --git a/lua/cmp_git/sort.lua b/lua/cmp_git/sort.lua index 0bd9bbb..319a6dc 100644 --- a/lua/cmp_git/sort.lua +++ b/lua/cmp_git/sort.lua @@ -7,12 +7,24 @@ local function gitea_updated_at(item) return string.format("%010d", os.difftime(os.time(), utils.parse_gitea_date(item.updated_at))) end +---The commits sort the newest first. +---@param commit cmp_git.Commit +---@return string +local function commit_age(commit) + return string.format("%010d", commit.diff) +end + local M = { git = { - ---@param commit cmp_git.Commit - commits = function(commit) -- nil, "sha", "title", "description", "author_name", "author_mail", "commit_timestamp", or custom function - return string.format("%010d", commit.diff) - end, + commits = commit_age, -- nil, "sha", "title", "description", "author_name", "author_mail", "commit_timestamp", or custom function + autosquash = { + ---@param prefix cmp_git.AutosquashPrefix + prefixes = function(prefix) -- nil, "word", or custom function + -- The order of the words + return string.format("%05d", prefix.index) + end, + commits = commit_age, -- nil, "sha", "title", "description", "author_name", "author_mail", "commit_timestamp", or custom function + }, trailers = { ---@param trailer cmp_git.TrailerKey keys = function(trailer) -- nil, "key", or custom function diff --git a/lua/cmp_git/source.lua b/lua/cmp_git/source.lua index 77a5fd8..c98cfd5 100644 --- a/lua/cmp_git/source.lua +++ b/lua/cmp_git/source.lua @@ -88,6 +88,25 @@ function Source:complete(params, callback) return self.core:complete(self:_context_from_params(params), callback) end +---nvim-cmp calls this after the confirm of an item has inserted its text. +---@param completion_item cmp_git.CompletionItem +---@param callback fun() +function Source:execute(completion_item, callback) + callback() + if not Core.reopens_menu(completion_item) then + return + end + + -- After the callback, which nvim-cmp schedules to end the confirm + vim.schedule(function() + -- Only nvim-cmp calls this, but the module can be missing for other callers + local ok, cmp = pcall(require, "cmp") + if ok then + cmp.complete({ config = { sources = { { name = "git" } } } }) + end + end) +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 diff --git a/lua/cmp_git/sources/git.lua b/lua/cmp_git/sources/git.lua index 1ef0e10..9f4622a 100644 --- a/lua/cmp_git/sources/git.lua +++ b/lua/cmp_git/sources/git.lua @@ -1,3 +1,4 @@ +local autosquash = require("cmp_git.provider.capabilities.autosquash") local commits = require("cmp_git.provider.capabilities.commits") local trailers = require("cmp_git.provider.capabilities.trailers") @@ -63,6 +64,28 @@ function Git:get_commits(callback, context, trigger_char) }) end +---The autosquash prefix words that start with the letters before the cursor. They start no process. +---@param callback fun(list: cmp_git.CompletionList) +function Git:get_autosquash_prefixes(callback, context) + callback(autosquash.prefixes.list(self.config.autosquash.prefixes, context.line or "")) + return true +end + +---The titles of earlier commits after an autosquash prefix. The commits come from the history and the cache of +---`get_commits`. +---@param callback fun(list: cmp_git.CompletionList) +function Git:get_autosquash_commits(callback, context) + return true, + autosquash.commits.complete({ + cache = self.cache_commits, + callback = callback, + commits_config = self.config.commits, + config = self.config.autosquash.commits, + line = context.line, + bufnr = context.bufnr or vim.api.nvim_get_current_buf(), + }) +end + ---@param callback fun(list: cmp_git.CompletionList) ---@param trigger_char string? function Git:get_trailer_keys(callback, context, trigger_char) diff --git a/tests/provider/capabilities/test_autosquash.lua b/tests/provider/capabilities/test_autosquash.lua new file mode 100644 index 0000000..b1deeca --- /dev/null +++ b/tests/provider/capabilities/test_autosquash.lua @@ -0,0 +1,132 @@ +local helpers = require("tests.helpers") +local config = require("cmp_git.config") +local autosquash = require("cmp_git.provider.capabilities.autosquash") + +local eq = helpers.eq +local ok = helpers.ok + +local T = helpers.new_set() + +---@param sha string +---@param title string +---@param diff integer +---@return cmp_git.Commit +local function commit(sha, title, diff) + return { + sha = sha, + title = title, + description = "", + author_name = "Ada Lovelace", + author_mail = "ada@example.com", + commit_timestamp = 1700000000 - diff, + diff = diff, + } +end + +-- Newest first, as `git log` gives them +local HISTORY = { + commit("1111111", "fixup! feat: add parser", 1), + commit("2222222", "feat: add parser", 2), + commit("3333333", "squash! docs: describe the parser", 3), + commit("4444444", "amend! feat: add parser", 4), + commit("5555555", "docs: describe the parser", 5), + commit("6666666", "feat: add parser", 6), + commit("7777777", "fixup!no space", 7), + commit("8888888", "Fixup! capital", 8), +} + +---Complete the line with the cached history and the default config. +---@param line string +---@return cmp_git.CompletionList list +---@return integer builds The processes that the request started. +local function complete(line) + local defaults = config.normalize({}).git + local builds = 0 + local callback, lists = helpers.recorder() + autosquash.commits.complete({ + cache = { [12] = HISTORY }, + callback = callback, + commits_config = defaults.commits, + config = defaults.autosquash.commits, + line = line, + runner = { + build = function() + builds = builds + 1 + end, + }, + bufnr = 12, + }) + eq(#lists, 1, "one list for " .. vim.inspect(line)) + return lists[1], builds +end + +T["autosquash_commits_hide_autosquash_commits_and_repeated_titles"] = function() + local list, builds = complete("fixup! ") + + eq(builds, 0, "the cached history starts no process") + eq(list.isIncomplete, false, "the list is final") + eq( + vim.tbl_map(function(item) + return item.label + end, list.items), + { + "feat: add parser", + "docs: describe the parser", + "fixup!no space", + "Fixup! capital", + }, + "only the titles that git does not read as autosquash commits, once each, newest first" + ) + eq(list.items[1].data.sha, "2222222", "a repeated title keeps the newest commit") + ok(list.items[1].sortText < list.items[2].sortText, "the newest commit sorts first") +end + +T["autosquash_commits_attached_trigger"] = function() + local attached = complete("fixup!").items[1] + eq(attached.insertText, " feat: add parser", "the title touches the `!`, so the insert adds a space") + eq(attached.filterText, "! feat: add parser", "the filter starts at the `!`") + eq(complete("fixup!fe").items[1].insertText, " feat: add parser", "typed text that touches the `!`") + + local spaced = complete("fixup! fe").items[1] + eq(spaced.insertText, "feat: add parser", "after the space, the insert is the title") + eq(spaced.filterText, "feat: add parser", "after the space, the filter is the title") +end + +---@param line string +---@param overrides? table The `git.autosquash.prefixes` overrides. +---@return cmp_git.CompletionList +local function prefixes(line, overrides) + local prefixes_config = + config.normalize({ git = { autosquash = { prefixes = overrides } } }).git.autosquash.prefixes + return autosquash.prefixes.list(prefixes_config, line) +end + +T["autosquash_prefixes"] = function() + eq(prefixes("f"), { items = {}, isIncomplete = true }, "fewer letters than the minimum ask again") + + local list = prefixes("fi") + eq(list.isIncomplete, false, "the list is final") + eq(#list.items, 1, "one item for fixup") + local item = list.items[1] + eq({ item.label, item.insertText, item.filterText }, { "fixup!", "fixup! ", "fixup" }, "the fixup item") + eq({ item.data.word, item.data.index, item.data.reopen }, { "fixup", 1, true }, "the item fields and the marker") + + eq(prefixes("am").items[1].data.index, 3, "the index is the position of the word") + eq(prefixes("f", { min_length = 1 }).items[1].label, "fixup!", "a minimum length of 1") + eq(prefixes("f", { min_length = 0 }).items[1].label, "fixup!", "a minimum length below 1 acts as 1") + eq(prefixes("", { min_length = 0 }), { items = {}, isIncomplete = true }, "no letters ask again") + eq(prefixes("fixup", { min_length = 9 }), { items = {}, isIncomplete = true }, "a full word below the minimum") + eq(prefixes("sq", { words = { "fixup" } }).items, {}, "only the words") + + local both = prefixes("s", { words = { "squash", "sign", "fixup" }, min_length = 1 }) + eq( + vim.tbl_map(function(prefix) + return prefix.label + end, both.items), + { "squash!", "sign!" }, + "each word that starts with the letters" + ) + ok(both.items[1].sortText < both.items[2].sortText, "the items keep the order of the words") +end + +return T diff --git a/tests/provider/capabilities/test_autosquash_lines.lua b/tests/provider/capabilities/test_autosquash_lines.lua new file mode 100644 index 0000000..6135e23 --- /dev/null +++ b/tests/provider/capabilities/test_autosquash_lines.lua @@ -0,0 +1,73 @@ +local helpers = require("tests.helpers") +local config = require("cmp_git.config") +local autosquash_lines = require("cmp_git.provider.capabilities.autosquash_lines") + +local eq = helpers.eq +local ok = helpers.ok + +local T = helpers.new_set() + +---@param lines string[] +---@param row integer +---@param line string +---@param kind "prefixes" | "commits" +---@param filetype? string +---@param overrides? table The `git.autosquash` overrides. +---@return boolean +local function available(lines, row, line, kind, filetype, overrides) + local bufnr = helpers.buffer(lines, filetype) + return autosquash_lines.available(config.normalize({ git = { autosquash = overrides } }).git.autosquash, { + bufnr = bufnr, + cursor = { row = row }, + line = line, + }, kind) +end + +T["autosquash_commits_gating"] = function() + ok(available({ "fixup!" }, 1, "fixup!", "commits"), "commits on line 1") + ok(available({ "fixup!" }, 1, "fixup!", "commits", "NeogitCommitMessage"), "commits in Neogit commit messages") + ok(available({ "fixup!" }, 1, "fixup!", "commits", "gitcommit.markdown"), "commits in a compound filetype") + ok(not available({ "subject", "", "fixup!" }, 3, "fixup!", "commits"), "no commits on line 3") + ok(not available({ "subject", "fixup!" }, 2, "fixup!", "commits"), "no commits on line 2") + ok(not available({ "fixup!" }, 1, "fixup!", "commits", "octo"), "no commits in octo") + ok(not available({ "fixup!" }, 1, "fixup!", "commits", "markdown"), "no commits in other filetypes") + ok(available({ "fixup! feat: x" }, 1, "fixup! feat: x", "commits"), "commits after a prefix and a title") + ok(not available({ "Fix the parser" }, 1, "Fix the parser", "commits"), "no commits without a prefix") + ok(not available({ "feat: x!" }, 1, "feat: x!", "commits"), "no commits for a `!` after other text") + ok(not available({ "!x" }, 1, "!x", "commits"), "no commits for a `!` without letters") + ok( + not autosquash_lines.available(config.normalize({}).git.autosquash, { line = "fixup!" }, "commits"), + "no commits without a cursor" + ) +end + +T["autosquash_prefixes_gating"] = function() + ok(available({ "f" }, 1, "f", "prefixes"), "a letter that starts a word") + ok(available({ "fi" }, 1, "fi", "prefixes"), "letters that start a word") + ok(available({ "fixup" }, 1, "fixup", "prefixes"), "a full word") + ok(available({ "sq" }, 1, "sq", "prefixes"), "squash") + ok(available({ "am" }, 1, "am", "prefixes"), "amend") + ok(available({ "fi" }, 1, "fi", "prefixes", "gitcommit.markdown"), "a compound filetype") + ok(not available({ "fe" }, 1, "fe", "prefixes"), "letters that start no word") + ok(not available({ "Fi" }, 1, "Fi", "prefixes"), "the words match case-sensitively") + ok(not available({ "fixups" }, 1, "fixups", "prefixes"), "letters after a full word") + ok(not available({ "" }, 1, "", "prefixes"), "no letters") + ok(not available({ "fi-" }, 1, "fi-", "prefixes"), "a character that is not a letter") + ok(not available({ "fixup!" }, 1, "fixup!", "prefixes"), "a prefix with its `!`") + ok(not available({ " fi" }, 1, " fi", "prefixes"), "a space before the letters") + ok(not available({ "subject", "", "fi" }, 3, "fi", "prefixes"), "no prefixes on line 3") + ok(not available({ "fi" }, 1, "fi", "prefixes", "octo"), "no prefixes in octo") + ok(not available({ "sq" }, 1, "sq", "prefixes", nil, { prefixes = { words = { "fixup" } } }), "only the words") + ok(available({ "re" }, 1, "re", "prefixes", nil, { prefixes = { words = { "reword" } } }), "a word of the user") +end + +T["autosquash_attached_trigger"] = function() + eq(autosquash_lines.attached_trigger("fixup!"), "!", "the text touches the `!`") + eq(autosquash_lines.attached_trigger("fixup!feat"), "!", "the title touches the `!`") + eq(autosquash_lines.attached_trigger("fixup! "), nil, "a space after the `!`") + eq(autosquash_lines.attached_trigger("fixup! feat"), nil, "a title after the space") + eq(autosquash_lines.attached_trigger("squash! fixup! x"), nil, "a repeated prefix after the space") + eq(autosquash_lines.attached_trigger(nil), nil, "no line") +end + +return T diff --git a/tests/provider/capabilities/test_commits.lua b/tests/provider/capabilities/test_commits.lua index 881978b..5c73a8d 100644 --- a/tests/provider/capabilities/test_commits.lua +++ b/tests/provider/capabilities/test_commits.lua @@ -2,6 +2,7 @@ local helpers = require("tests.helpers") local commit_capability = require("cmp_git.provider.capabilities.commits") local eq = helpers.eq +local ok = helpers.ok local T = helpers.new_set() @@ -302,6 +303,64 @@ 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_items_function_reuses_the_cached_history"] = function() + local builds = 0 + local raw = commit_record("abcdef1234567890", "cache me", "body", "Author", "author@example.com", 1234567890) + local runner = { + build = function(_spec, callback) + builds = builds + 1 + return helpers.fake_job(function() + callback(raw, true) + end) + end, + } + local cache = {} + local seen = {} + local function titles(commits, config, trigger_char) + table.insert(seen, { config = config, trigger_char = trigger_char }) + return vim.tbl_map(function(commit) + return { label = commit.title } + end, commits) + end + local config = commit_test_config() + local callback, lists = helpers.recorder() + + commit_capability.complete({ + cache = cache, + callback = callback, + config = config, + trigger_char = ":", + runner = runner, + bufnr = 46, + }) + commit_capability.complete({ + cache = cache, + callback = callback, + config = config, + items = titles, + runner = runner, + bufnr = 46, + }) + + eq(builds, 1, "the cached history starts no new process") + eq(lists[1].items[1].label, ":abcdef1: cache me", "the default items function") + eq(lists[2], { items = { { label = "cache me" } }, isIncomplete = false }, "the items function makes the items") + eq(#seen, 1, "the items function runs for its own request only") + ok(seen[1].config == config, "the items function gets the config") + eq(seen[1].trigger_char, nil, "the items function gets the trigger character") + + commit_capability.complete({ + cache = {}, + callback = callback, + config = config, + items = titles, + runner = runner, + bufnr = 47, + }) + eq(builds, 2, "an empty cache starts git log") + eq(lists[3].items[1].label, "cache me", "the items function makes the items of a new history") +end + T["commit_capability_answers_failures_without_caching"] = function() local builds = 0 local failing_runner = { diff --git a/tests/sources/test_git.lua b/tests/sources/test_git.lua index 8d9619c..40b1035 100644 --- a/tests/sources/test_git.lua +++ b/tests/sources/test_git.lua @@ -1,6 +1,8 @@ local helpers = require("tests.helpers") local config = require("cmp_git.config") +local Core = require("cmp_git.core") local Git = require("cmp_git.sources.git") +local command = require("cmp_git.command") local eq = helpers.eq local ok = helpers.ok @@ -86,6 +88,163 @@ T["git_requests_answer_in_a_repository_without_commits"] = function() eq(next(git.cache_trailer_values), nil, "trailer values are not cached") end +T["git_autosquash_commits_reuse_the_commit_history"] = function() + if vim.fn.executable("git") ~= 1 then + helpers.skip("git is not installed") + end + + local repo = helpers.git_repo() + repo.commit("feat: add parser (#12)") + repo.commit("fixup! feat: add parser (#12)") + repo.commit("docs: describe the parser") + vim.fn.chdir(repo.dir) + local git = Git.new(config.normalize({}).git) + local bufnr = helpers.buffer({ "fixup!" }) + + local commits = {} + git:get_commits(function(list) + table.insert(commits, list) + end, { bufnr = bufnr }, ":") + helpers.wait_for(function() + return #commits > 0 + end, "the commits call back") + + local builds = 0 + helpers.stub(command, "build", function() + builds = builds + 1 + end) + local callback, lists = helpers.recorder() + local handled = git:get_autosquash_commits(callback, { + bufnr = bufnr, + cursor = { row = 1, character = 6 }, + line = "fixup!", + }) + + ok(handled, "the commit list is handled") + eq(builds, 0, "the commit list after a `:` request starts no git process") + eq(#lists, 1, "the commit list calls back at once") + eq(labels(lists[1]), { "docs: describe the parser", "feat: add parser (#12)" }, "the titles, newest first") + eq(lists[1].items[1].insertText, " docs: describe the parser", "the title touches the `!`") +end + +T["git_autosquash_prefixes"] = function() + local git = Git.new(config.normalize({}).git) + local callback, lists = helpers.recorder() + + ok(git:get_autosquash_prefixes(callback, { cursor = { row = 1, character = 2 }, line = "sq" }), "handled") + eq(#lists, 1, "the prefixes call back at once") + eq(labels(lists[1]), { "squash!" }, "the prefix of the letters") +end + +---The commands and the subjects of a `git rebase -i` todo list, such as `fixup -C amend! fix: x`. +---@param todo string +---@return string[] +local function todo_steps(todo) + local steps = {} + for _, line in ipairs(vim.split(todo, "\n")) do + if line ~= "" and not line:find("^#") then + local step, rest = line:match("^(%a+)%s+(.*)$") + local option = rest:match("^%-[Cc]%s+") + if option then + step = step .. " " .. vim.trim(option) + rest = rest:sub(#option + 1) + end + -- Git 2.4x and later put `# ` before the subject + local subject = rest:match("^%x+%s+(.*)$"):gsub("^# ", "") + table.insert(steps, step .. " " .. subject) + end + end + return steps +end + +T["git_autosquash_subjects_match_the_rebase_rules"] = function() + if vim.fn.executable("git") ~= 1 then + helpers.skip("git is not installed") + end + + local repo = helpers.git_repo() + local function commit(file, message, body) + vim.fn.writefile({ message }, repo.dir .. "/" .. file, "a") + repo.git("add", file) + repo.git("commit", "-q", "--no-verify", "-m", message, body and "-m" or nil, body) + end + commit("base.txt", "chore: start") + local base = vim.trim(repo.git("rev-parse", "HEAD")) + commit("parser.lua", "feat: add parser (#12)") + commit("README.md", "docs: describe the parser") + commit("input.lua", "fix: handle empty input") + + local core = Core.new({}) + -- Each line is typed in a new commit message, and its item is the commit with the title of `target` + local cases = { + { line = "fixup!", trigger = "!", target = "feat: add parser (#12)", file = "parser.lua" }, + { line = "squash! do", target = "docs: describe the parser", file = "README.md" }, + { line = "amend!fi", trigger = "!", target = "fix: handle empty input", file = "input.lua" }, + } + for _, case in ipairs(cases) do + local bufnr = helpers.buffer({ case.line }) + vim.api.nvim_buf_set_name(bufnr, repo.dir .. "/.git/COMMIT_EDITMSG") + local callback, lists = helpers.recorder() + core:complete({ + trigger_character = case.trigger, + cursor = { row = 1, character = #case.line }, + line = case.line, + bufnr = bufnr, + }, callback) + helpers.wait_for(function() + return #lists > 0 + end, vim.inspect(case.line) .. " answers") + + local item = vim.iter(lists[1].items):find(function(candidate) + return candidate.label == case.target + end) + helpers.not_nil(item, vim.inspect(case.line) .. " offers " .. case.target) + local range = item.textEdit.range + vim.api.nvim_buf_set_text(bufnr, 0, range.start.character, 0, range["end"].character, { item.textEdit.newText }) + local subject = vim.api.nvim_buf_get_lines(bufnr, 0, 1, false)[1] + vim.api.nvim_buf_delete(bufnr, { force = true }) + + local prefix = case.line:match("^%a+!") + eq(subject, prefix .. " " .. case.target, vim.inspect(case.line) .. " completes the subject") + -- Git stops the rebase when the new message of an `amend!` commit is empty + commit(case.file, subject, prefix == "amend!" and "fix: handle empty and blank input" or nil) + end + + local todo = helpers.tempdir() .. "/todo" + local editor = helpers.tempdir() .. "/save-todo" + vim.fn.writefile({ "#!/bin/sh", string.format('cp "$1" %s', vim.fn.shellescape(todo)) }, editor) + vim.uv.fs_chmod(editor, tonumber("755", 8)) + local result = vim.system({ + "git", + "-C", + repo.dir, + "-c", + "user.name=Ada Lovelace", + "-c", + "user.email=ada@example.com", + "rebase", + "-i", + "--autosquash", + base, + }, { text = true, env = { GIT_SEQUENCE_EDITOR = editor, GIT_EDITOR = "true" } }):wait() + + eq(result.code, 0, "the rebase succeeds: " .. (result.stderr or "")) + eq(todo_steps(table.concat(vim.fn.readfile(todo), "\n")), { + "pick feat: add parser (#12)", + "fixup fixup! feat: add parser (#12)", + "pick docs: describe the parser", + "squash squash! docs: describe the parser", + "pick fix: handle empty input", + "fixup -C amend! fix: handle empty input", + }, "each autosquash commit comes right after its target") + eq(vim.fn.isdirectory(repo.dir .. "/.git/rebase-merge"), 0, "no rebase is in progress") + eq( + vim.split(vim.trim(repo.git("log", "--format=%s", base .. "..HEAD")), "\n"), + { "fix: handle empty and blank input", "docs: describe the parser", "feat: add parser (#12)" }, + "the rebase folds each autosquash commit into its target" + ) +end + T["git_commit_documentation_keeps_the_body_layout"] = function() if vim.fn.executable("git") ~= 1 then helpers.skip("git is not installed") diff --git a/tests/test_blink.lua b/tests/test_blink.lua index 0c54656..346ed31 100644 --- a/tests/test_blink.lua +++ b/tests/test_blink.lua @@ -1,5 +1,6 @@ local helpers = require("tests.helpers") local Blink = require("cmp_git.blink") +local repository = require("cmp_git.repository") local eq = helpers.eq @@ -148,4 +149,106 @@ T["blink_adapter_matches_text_before_cursor"] = function() eq(received.cursor.character, 5, "blink converts the cursor column") end +---A prefix word item, as the autosquash capability gives it and blink.cmp keeps it. +local PREFIX_ITEM = { + label = "fixup!", + insertText = "fixup! ", + data = { word = "fixup", index = 1, reopen = true }, + source_id = "cmp_git", +} +---A commit item of the autosquash commit list. +local COMMIT_ITEM = { + label = "feat: add parser", + insertText = "feat: add parser", + data = { sha = "abcdef1" }, + source_id = "cmp_git", +} + +---Replace the `blink.cmp` module with a stub that records the `show` calls in `events`. +---@param events table[] +local function stub_blink(events) + helpers.stub(package.loaded, "blink.cmp", { + show = function(opts) + table.insert(events, { "show", opts }) + end, + }) +end + +T["blink_execute_reopens_the_menu_after_a_prefix_item"] = function() + local blink = Blink.new({}) + local events = {} + stub_blink(events) + local function callback() + table.insert(events, { "callback" }) + end + local function default_implementation() + table.insert(events, { "default_implementation" }) + end + + blink:execute({}, PREFIX_ITEM, callback, default_implementation) + eq(events, { { "default_implementation" }, { "callback" } }, "the insert and the callback run at once") + helpers.flush() + eq( + events, + { { "default_implementation" }, { "callback" }, { "show", { providers = { "cmp_git" } } } }, + "one show for the provider of the item, after the callback" + ) + + events = {} + stub_blink(events) + blink:execute({}, COMMIT_ITEM, callback, default_implementation) + helpers.flush() + eq(events, { { "default_implementation" }, { "callback" } }, "a commit item does not reopen the menu") +end + +T["blink_execute_without_blink"] = function() + local blink = Blink.new({}) + helpers.stub(package.loaded, "blink.cmp", nil) + helpers.stub(package.preload, "blink.cmp", function() + error("module 'blink.cmp' not found") + end) + -- A scheduled error would only print a message, so the reopen runs at once + helpers.stub(vim, "schedule", function(fn) + fn() + end) + local calls = {} + + blink:execute({}, PREFIX_ITEM, function() + table.insert(calls, "callback") + end, function() + table.insert(calls, "default_implementation") + end) + + eq(calls, { "default_implementation", "callback" }, "the insert and the callback run, and execute raises no error") +end + +T["blink_reopen_request_gives_the_commit_items"] = function() + helpers.stub(repository, "is_git_repo", function(on_result) + on_result(true) + end) + local blink = Blink.new({}) + local bufnr = helpers.buffer({ "fixup! " }) + blink.core.sources.git.cache_commits[bufnr] = { + { + sha = "abcdef1", + title = "feat: add parser", + description = "", + author_name = "Ada Lovelace", + author_mail = "ada@example.com", + commit_timestamp = 1700000000, + diff = 1, + }, + } + local result + + blink:get_completions({ cursor = { 1, 7 }, line = "fixup! ", bufnr = bufnr }, function(response) + result = response + end) + + local item = helpers.not_nil(result, "the request answers").items[1] + eq(item.label, "feat: add parser", "the commit items") + eq(item.textEdit.range.start.character, 7, "the edit starts after the prefix and its space") + eq(item.textEdit.newText, "feat: add parser", "the insert is the title") +end + return T diff --git a/tests/test_config.lua b/tests/test_config.lua index ff33c3f..c838847 100644 --- a/tests/test_config.lua +++ b/tests/test_config.lua @@ -8,6 +8,19 @@ local ok = helpers.ok local T = helpers.new_set() +---@param trigger_actions table[] +---@param action string +---@return integer index +---@return table entry +local function entry_with_action(trigger_actions, action) + for index, entry in ipairs(trigger_actions) do + if vim.tbl_contains(entry.actions or {}, action) then + return index, entry + end + end + error("no entry with the action " .. action) +end + T["config_normalize_fills_defaults_and_hosts"] = function() local normalized = config.normalize({ github = { @@ -212,11 +225,14 @@ end T["config_trailer_defaults"] = function() local normalized = config.normalize({}) - eq(normalized.trigger_actions[1].actions[1], "git_trailer_keys", "trailer keys action is first") - eq(normalized.trigger_actions[2].actions[1], "git_trailer_identities", "trailer identities action is second") - eq(normalized.trigger_actions[2].actions[2], "git_trailer_values", "trailer values action follows identities") + local keys = entry_with_action(normalized.trigger_actions, "git_trailer_keys") + eq( + normalized.trigger_actions[keys + 1].actions, + { "git_trailer_identities", "git_trailer_values" }, + "trailer values entry follows the keys entry, with identities first" + ) eq(normalized.git.trailers.values.limit, 1000, "trailer values scan limit") - eq(normalized.trigger_actions[3].trigger_character, ":", "commit trigger follows trailer actions") + eq(normalized.trigger_actions[keys + 2].trigger_character, ":", "commit trigger follows trailer actions") eq(normalized.git.trailers.keys.limit, 100, "trailer keys scan limit") eq(normalized.git.trailers.identities.limit, 1000, "trailer identities scan limit") eq(normalized.git.trailers.keys.builtin[1], "Co-authored-by", "built-in keys start with Co-authored-by") @@ -231,8 +247,62 @@ T["config_trailer_defaults"] = function() ok(not vim.tbl_contains(normalized.git.trailers.filetypes, "octo"), "no trailers in octo") end +T["config_autosquash_defaults"] = function() + local normalized = config.normalize({}) + local autosquash = normalized.git.autosquash + + eq(autosquash.filetypes, { "gitcommit", "NeogitCommitMessage" }, "autosquash in commit messages") + eq(type(autosquash.commits.format.insertText), "function", "the commit format") + eq(type(autosquash.commits.sort_by), "function", "the commit sort") + + eq(autosquash.prefixes.words, { "fixup", "squash", "amend" }, "the prefix words of git") + eq(autosquash.prefixes.min_length, 2, "the prefix words show from the second letter") + eq(type(autosquash.prefixes.format.insertText), "function", "the prefix format") + eq(type(autosquash.prefixes.sort_by), "function", "the prefix sort") + + local _, keys_entry = entry_with_action(normalized.trigger_actions, "git_trailer_keys") + local prefixes_entry = normalized.trigger_actions[1] + eq(prefixes_entry.actions, { "git_autosquash_prefixes" }, "the prefix entry comes first") + eq(prefixes_entry.pattern, keys_entry.pattern, "the prefix entry shares the pattern of the trailer keys") + eq(prefixes_entry.keyword_pattern, nil, "the prefix entry adds no keyword pattern") + + local entry = normalized.trigger_actions[2] + eq(entry.actions, { "git_autosquash" }, "the commit list entry follows") + eq(entry.pattern, [[^\%(fixup\|squash\|amend\)!\s*\zs.*]], "the commit list starts after the prefix") + eq( + entry.keyword_pattern, + [[^\%(fixup\|squash\|amend\)\%(\zs!\S*\|!\s*\zs.*\)]], + "the keyword starts at a `!` that the typed text touches" + ) + eq(entry_with_action(normalized.trigger_actions, "git_trailer_keys"), 3, "the trailer entries follow") + for _, field in ipairs({ "pattern", "keyword_pattern" }) do + eq(entry[field]:find([[\@<=]], 1, true), nil, field .. " has no lookbehind") + end +end + +T["config_autosquash_partial_overrides_keep_the_defaults"] = function() + local words = config.normalize({ git = { autosquash = { prefixes = { words = { "fixup" } } } } }).git.autosquash + eq(words.prefixes.words, { "fixup" }, "a word list replaces the default words") + eq(words.prefixes.min_length, 2, "a word list keeps the minimum length") + eq(type(words.prefixes.format.label), "function", "a word list keeps the format") + eq(words.filetypes, { "gitcommit", "NeogitCommitMessage" }, "a word list keeps the filetypes") + + local min_length = config.normalize({ git = { autosquash = { prefixes = { min_length = 1 } } } }).git.autosquash + eq(min_length.prefixes.min_length, 1, "a minimum length") + eq(min_length.prefixes.words, { "fixup", "squash", "amend" }, "a minimum length keeps the words") + eq(type(min_length.commits.format.label), "function", "a minimum length keeps the commit format") +end + +T["config_autosquash_empty_lists_replace_the_defaults"] = function() + local autosquash = + config.normalize({ git = { autosquash = { filetypes = {}, prefixes = { words = {} } } } }).git.autosquash + + eq(autosquash.filetypes, {}, "no filetypes turn off autosquash completion") + eq(autosquash.prefixes.words, {}, "no words turn off the prefix words") +end + T["config_trailer_value_pattern_has_no_lookbehind"] = function() - local value_entry = config.normalize({}).trigger_actions[2] + local _, value_entry = entry_with_action(config.normalize({}).trigger_actions, "git_trailer_values") -- 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") @@ -243,7 +313,7 @@ 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 _, defaults = entry_with_action(config.normalize({}).trigger_actions, "git_trailer_values") local normalized = config.normalize({ trigger_actions = { user_entry, custom_entry } }) diff --git a/tests/test_core.lua b/tests/test_core.lua index 2b52fa0..0ed271a 100644 --- a/tests/test_core.lua +++ b/tests/test_core.lua @@ -208,6 +208,13 @@ T["core_keyword_pattern_offsets"] = function() { "cc @ad", 3 }, { "see !4", 4 }, { "body text he", nil }, + { "fixup!", 5 }, + { "fixup!feat", 5 }, + { "fixup! ", 7 }, + { "fixup! a", 7 }, + { "fixup! feat: a", 7 }, + { "amend! fix #12 x", 7 }, + { "squash! fixup! x", 8 }, } for _, case in ipairs(cases) do @@ -295,24 +302,28 @@ T["trailer_value_pattern_matches_the_lookbehind_pattern"] = function() end end -T["trailer_patterns_are_fast_on_long_lines"] = function() +T["trigger_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) + eq(action_regex(core, "git_autosquash") ~= nil, true, "the autosquash pattern is measured") + -- A long body line, and a long subject after an autosquash prefix, which the autosquash patterns match to the end + local lines = { string.rep("parser cache ", 400), "fixup! " .. string.rep("parser cache ", 400) } - local start = vim.uv.hrtime() - for _ = 1, 3 do - for _, regex in ipairs(regexes) do - regex:match_str(line) + for _, line in ipairs(lines) do + local start = vim.uv.hrtime() + for _ = 1, 3 do + for _, regex in ipairs(regexes) do + regex:match_str(line) + end end - end - local elapsed_ms = (vim.uv.hrtime() - start) / 1e6 + 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)) + -- The lookbehind trailer value pattern took about 2 s here, and the `\zs` patterns take about 1 ms + ok(elapsed_ms < 200, string.format("patterns take %.1f ms on %s...", elapsed_ms, vim.inspect(line:sub(1, 20)))) + end end T["trailer_routing"] = function() @@ -345,6 +356,7 @@ T["trailer_routing"] = function() local cases = { { line = "Co-auth", expected = "keys" }, + { line = "Co", expected = "keys" }, { line = "Co-authored-by:", trigger = ":", expected = "identities" }, { line = "Co-authored-by: Ada", expected = "identities" }, { line = "Co-authored-by: Ada .` of each call. +local function core_with_recorded_remote() + local core = Core.new({}) + local calls = {} + helpers.stub(repository, "is_git_repo", function(on_result) + on_result(true) + end) + helpers.stub(repository, "discover", function(_, opts) + table.insert(calls, "discover") + opts.on_complete({ host = "gitlab.com", owner = "group", repo = "project" }) + end) + for _, name in ipairs({ "github", "gitlab", "gitea", "forgejo" }) do + local methods = {} + for _, method in ipairs({ "complete_issues", "complete_change_requests", "complete_mentions" }) do + methods[method] = function(_, callback) + table.insert(calls, name .. "." .. method) + callback({ items = {}, isIncomplete = false }) + return true + end + end + rawset(core.sources, name, methods) + end + return core, calls +end + +T["autosquash_commit_items_on_line_1"] = function() + local core, calls = core_with_recorded_remote() + local cases = { + { line = "fixup!", trigger = "!", edit_start = 6, insert = " docs: describe the parser" }, + { line = "fixup!feat", trigger = "!", edit_start = 6, insert = " docs: describe the parser" }, + { line = "fixup! fe", edit_start = 7, insert = "docs: describe the parser" }, + { line = "fixup! feat:", trigger = ":", edit_start = 7, insert = "docs: describe the parser" }, + { line = "amend! fix #12", trigger = "#", edit_start = 7, insert = "docs: describe the parser" }, + { line = "squash! fixup! x", edit_start = 8, insert = "docs: describe the parser" }, + } + + for _, case in ipairs(cases) do + local bufnr = helpers.buffer({ case.line }) + core.sources.git.cache_commits[bufnr] = HISTORY + local callback, lists = helpers.recorder() + core:complete({ + trigger_character = case.trigger, + cursor = { row = 1, character = #case.line }, + line = case.line, + bufnr = bufnr, + }, callback) + + local name = vim.inspect(case.line) + eq(#lists, 1, name .. " answers once") + eq( + vim.tbl_map(function(item) + return item.label + end, lists[1].items), + { "docs: describe the parser", "feat: add parser (#12)" }, + name .. " gives the titles" + ) + local edit = lists[1].items[1].textEdit + eq(edit.range.start.character, case.edit_start, name .. " edit start") + eq(edit.range["end"].character, #case.line, name .. " edit end") + eq(edit.newText, case.insert, name .. " inserts the title") + end + eq(calls, {}, "the commit list needs no remote, so a GitLab remote gives no merge requests") +end + +---Complete the line on line 1 of a commit message. +---@param line string +---@param overrides? table +---@return cmp_git.CompletionList list +---@return integer checks The repository checks of the request. +local function complete_line_1(line, overrides) + local core = Core.new(overrides or {}) + local checks = 0 + helpers.stub(repository, "is_git_repo", function(on_result) + checks = checks + 1 + on_result(true) + end) + local callback, lists = helpers.recorder() + core:complete({ cursor = { row = 1, character = #line }, line = line, bufnr = helpers.buffer({ line }) }, callback) + eq(#lists, 1, vim.inspect(line) .. " answers once") + return lists[1], checks +end + +---@param list cmp_git.CompletionList +---@return string[] +local function labels(list) + return vim.tbl_map(function(item) + return item.label + end, list.items) +end + +T["autosquash_prefix_items_on_line_1"] = function() + local declined = { items = {}, isIncomplete = false } + + eq(complete_line_1("f"), { items = {}, isIncomplete = true }, "f asks again on the next letter") + + local list = complete_line_1("fi") + eq(labels(list), { "fixup!" }, "fi gives fixup!") + local item = list.items[1] + eq(item.textEdit.range.start.character, 0, "the edit starts at the line start") + eq(item.textEdit.range["end"].character, 2, "the edit ends at the cursor") + eq(item.textEdit.newText, "fixup! ", "the insert is the prefix and a space") + eq(item.data.reopen, true, "the item has the reopen marker") + + for _, line in ipairs({ "fe", "Fi" }) do + local result, checks = complete_line_1(line) + eq(result, declined, vim.inspect(line) .. " gives no autosquash item") + eq(checks, 0, vim.inspect(line) .. " checks no repository") + end + eq(labels(complete_line_1("sq")), { "squash!" }, "sq gives squash!") + eq(labels(complete_line_1("am")), { "amend!" }, "am gives amend!") + local min_length = { git = { autosquash = { prefixes = { min_length = 1 } } } } + eq(labels(complete_line_1("f", min_length)), { "fixup!" }, "f with min_length 1 gives fixup!") + local words = { git = { autosquash = { prefixes = { words = { "fixup" } } } } } + eq(complete_line_1("sq", words), declined, "sq with the words { fixup } gives no autosquash item") +end + +T["autosquash_prefixes_fall_through_to_the_next_entries"] = function() + local core = Core.new({ git = { trailers = { min_line = 1 } } }) + local calls = {} + helpers.stub(core.sources.git, "get_trailer_keys", function() + table.insert(calls, "keys") + return true + end) + local function run(line) + calls = {} + core:_run_trigger_actions( + nil, + function() end, + { cursor = { row = 1, character = #line }, line = line, bufnr = helpers.buffer({ line }) }, + {} + ) + return table.concat(calls, ",") + end + + eq(run("fe"), "keys", "letters that start no word go to the trailer keys of the same regex") + eq(run("fi"), "", "letters that start a word give the prefix words") +end + +T["autosquash_prefixes_start_no_process"] = function() + if vim.fn.executable("git") ~= 1 then + helpers.skip("git is not installed") + end + + vim.fn.chdir(helpers.git_repo().dir) + local core = Core.new({}) + local context = { cursor = { row = 1, character = 2 }, line = "fi", bufnr = helpers.buffer({ "fi" }) } + local callback, lists = helpers.recorder() + core:complete(context, callback) + helpers.wait_for(function() + return #lists == 1 + end, "the first request answers") + + local spawns = 0 + local system = vim.system + helpers.stub(vim, "system", function(...) + spawns = spawns + 1 + return system(...) + end) + core:complete(context, callback) + + eq(spawns, 0, "a request after the repository check starts no process") + eq(#lists, 2, "the request answers at once") + eq(labels(lists[2]), { "fixup!" }, "the request gives the prefix") +end + +T["autosquash_keeps_the_bang_trigger_elsewhere"] = function() + local core, calls = core_with_recorded_remote() + local function run(lines, row, filetype) + for key in pairs(calls) do + calls[key] = nil + end + local bufnr = helpers.buffer(lines, filetype) + core.sources.git.cache_commits[bufnr] = HISTORY + local callback, lists = helpers.recorder() + core:complete( + { trigger_character = "!", cursor = { row = row, character = 6 }, line = "fixup!", bufnr = bufnr }, + callback + ) + eq(#lists, 1, "the request answers once") + return table.concat(calls, ",") + end + + eq(run({ "subject", "", "fixup!" }, 3), "discover,gitlab.complete_change_requests", "`!` on line 3") + eq(run({ "fixup!" }, 1, "octo"), "discover,gitlab.complete_change_requests", "`!` on line 1 of an octo buffer") +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. @@ -400,7 +631,12 @@ T["routing_checks_availability_first_and_matches_each_regex_once"] = function() 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") + -- The prefix words share the regex of the trailer keys, so its count has the name of the prefix action + eq( + counts, + { git_autosquash_prefixes = 0, git_autosquash = 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 } @@ -408,7 +644,11 @@ T["routing_checks_availability_first_and_matches_each_regex_once"] = function() 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") + eq( + counts, + { git_autosquash_prefixes = 1, git_autosquash = 0, git_trailer_identities = 1 }, + "the value actions share one match, and autosquash is only on line 1" + ) helpers.stub(repository, "is_git_repo", function(on_result) on_result(true) @@ -424,9 +664,13 @@ T["routing_checks_availability_first_and_matches_each_regex_once"] = function() return true end, } - counts.git_trailer_keys, counts.git_trailer_identities = 0, 0 + counts.git_autosquash_prefixes, 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") + eq( + counts, + { git_autosquash_prefixes = 1, git_autosquash = 0, git_trailer_identities = 1 }, + "a request matches each regex once" + ) end T["core_pre_route_skips_repository"] = function() @@ -844,6 +1088,7 @@ T["setup_and_a_declined_request_load_only_the_core_modules"] = function() "cmp_git.core", "cmp_git.format", "cmp_git.log", + "cmp_git.provider.capabilities.autosquash_lines", "cmp_git.provider.capabilities.trailer_lines", "cmp_git.sort", "cmp_git.source", diff --git a/tests/test_format.lua b/tests/test_format.lua index f979e35..7cac85d 100644 --- a/tests/test_format.lua +++ b/tests/test_format.lua @@ -43,6 +43,67 @@ T["git_commits"] = function() ok(item.data == commit, "data is the commit") end +T["git_autosquash_commits"] = function() + local commit = { + sha = "abcdef1", + title = "feat: add parser (#12)", + description = "Body text", + author_name = "Ada Lovelace", + author_mail = "ada@example.com", + commit_timestamp = 1234567890, + diff = 5, + } + local documentation = format.item(defaults.git.commits, ":", commit).documentation.value + + eq(fields(format.item(defaults.git.autosquash.commits, nil, commit)), { + label = "feat: add parser (#12)", + insertText = "feat: add parser (#12)", + filterText = "feat: add parser (#12)", + documentation = documentation, + }, "after the prefix and a space") + local attached = format.item(defaults.git.autosquash.commits, "!", commit) + eq(fields(attached), { + label = "feat: add parser (#12)", + insertText = " feat: add parser (#12)", + filterText = "! feat: add parser (#12)", + documentation = documentation, + }, "when the typed text touches the `!`") + eq(attached.sortText, "0000000005", "sort text comes from sort_by") +end + +T["git_autosquash_prefixes"] = function() + local documentation = {} + for index, word in ipairs({ "fixup", "squash", "amend", "reword" }) do + local item = format.item(defaults.git.autosquash.prefixes, nil, { word = word, index = index }) + eq(item.label, word .. "!", word .. " label") + eq(item.filterText, word, word .. " filter text") + eq(item.insertText, word .. "! ", word .. " insert text") + eq(item.documentation.kind, "markdown", word .. " documentation is markdown") + documentation[word] = item.documentation.value + end + + eq( + documentation.fixup, + "# fixup!\n\n`git rebase --autosquash` adds the changes of this commit to the commit with the title after the prefix, and keeps the message of that commit.", + "fixup documentation" + ) + eq( + documentation.squash, + "# squash!\n\n`git rebase --autosquash` adds the changes of this commit to the commit with the title after the prefix, and combines the two messages.", + "squash documentation" + ) + eq( + documentation.amend, + "# amend!\n\n`git rebase --autosquash` adds the changes of this commit to the commit with the title after the prefix, and replaces the message of that commit with the message of this commit.", + "amend documentation" + ) + eq( + documentation.reword, + "# reword!\n\n`git rebase --autosquash` reads only the prefixes `fixup!`, `squash!`, and `amend!`.", + "documentation of a word that git does not read" + ) +end + T["github_issues"] = function() local item = format.item(defaults.github.issues, "#", { number = 12, diff --git a/tests/test_sort.lua b/tests/test_sort.lua index ed8977c..4f1540b 100644 --- a/tests/test_sort.lua +++ b/tests/test_sort.lua @@ -22,6 +22,15 @@ end T["default_git_sorts"] = function() eq(sort.get_sort_text(sort.git.commits, { diff = 42 }), "0000000042", "commits sort by age") + ok( + sort.git.autosquash.commits({ diff = 5 }) < sort.git.autosquash.commits({ diff = 50 }), + "autosquash commits sort newest first" + ) + ok( + sort.git.autosquash.prefixes({ word = "squash", index = 2 }) + < sort.git.autosquash.prefixes({ word = "amend", index = 10 }), + "autosquash prefixes keep the order of the words" + ) local used_more = sort.git.trailers.keys({ key = "Fixes", count = 2, builtin_index = 9 }) local builtin_first = sort.git.trailers.keys({ key = "Signed-off-by", count = 1, builtin_index = 2 }) diff --git a/tests/test_source.lua b/tests/test_source.lua index adb75bc..1331b9c 100644 --- a/tests/test_source.lua +++ b/tests/test_source.lua @@ -294,6 +294,94 @@ T["nvim_cmp_special_trigger_characters"] = function() eq(triggers, { "%", "]" }, "special trigger characters route") end +---A prefix word item, as the autosquash capability gives it. +local PREFIX_ITEM = { label = "fixup!", insertText = "fixup! ", data = { word = "fixup", index = 1, reopen = true } } +---A commit item of the autosquash commit list. +local COMMIT_ITEM = { label = "feat: add parser", insertText = "feat: add parser", data = { sha = "abcdef1" } } + +---Replace the `cmp` module with a stub that records the `complete` calls in `events`. +---@param events table[] +local function stub_cmp(events) + helpers.stub(package.loaded, "cmp", { + complete = function(option) + table.insert(events, { "complete", option }) + end, + }) +end + +T["nvim_cmp_execute_reopens_the_menu_after_a_prefix_item"] = function() + local source = Source.new({}) + local events = {} + stub_cmp(events) + local function callback() + table.insert(events, { "callback" }) + end + + source:execute(PREFIX_ITEM, callback) + eq(events, { { "callback" } }, "the callback runs at once, and the reopen waits for nvim-cmp to finish the confirm") + helpers.flush() + eq( + events, + { { "callback" }, { "complete", { config = { sources = { { name = "git" } } } } } }, + "one request with only the git source, after the callback" + ) + + events = {} + stub_cmp(events) + source:execute(COMMIT_ITEM, callback) + helpers.flush() + eq(events, { { "callback" } }, "a commit item runs the callback and does not reopen the menu") +end + +T["nvim_cmp_execute_without_nvim_cmp"] = function() + local source = Source.new({}) + helpers.stub(package.loaded, "cmp", nil) + helpers.stub(package.preload, "cmp", function() + error("module 'cmp' not found") + end) + -- A scheduled error would only print a message, so the reopen runs at once + helpers.stub(vim, "schedule", function(fn) + fn() + end) + local called = false + + source:execute(PREFIX_ITEM, function() + called = true + end) + + ok(called, "the callback runs, and execute raises no error") +end + +T["nvim_cmp_reopen_request_gives_the_commit_items"] = function() + helpers.stub(repository, "is_git_repo", function(on_result) + on_result(true) + end) + local source = Source.new({}) + local bufnr = helpers.buffer({ "fixup! " }) + vim.api.nvim_set_current_buf(bufnr) + source.sources.git.cache_commits[bufnr] = { + { + sha = "abcdef1", + title = "feat: add parser", + description = "", + author_name = "Ada Lovelace", + author_mail = "ada@example.com", + commit_timestamp = 1700000000, + diff = 1, + }, + } + local callback, lists = helpers.recorder() + + -- `cmp.complete` asks with the Invoked trigger kind + source:complete(cmp_params("fixup! ", 1), callback) + + eq(#lists, 1, "the request answers") + local item = lists[1].items[1] + eq(item.label, "feat: add parser", "the commit items") + eq(item.textEdit.range.start.character, 7, "the edit starts after the prefix and its space") + eq(item.textEdit.newText, "feat: add parser", "the insert is the title") +end + T["source_new_uses_normalized_config"] = function() local source = Source.new({ github = {