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 = {