diff --git a/docs/configuration.md b/docs/configuration.md index 6c62237..aa3ada1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -157,6 +157,8 @@ For compatibility, cmp-git also supports `filter_fn` on a provider, as an alias - `sha` has `git.commits.sha_length` characters (default 7). The default `insertText` inserts `sha`. The default `label` shows the first 7 characters. +- `description` is the body of the commit, without the blank lines at its start and the whitespace at its end. When + all lines with text share an indentation, that indentation is removed, and lines with only whitespace become empty. - `author_name` and `author_mail` are the name and the email of the author, with the `.mailmap` applied. `commit_timestamp` is the commit date. - GitHub issues and pull requests always have `body` and `updatedAt`. diff --git a/lua/cmp_git/provider/capabilities/commits.lua b/lua/cmp_git/provider/capabilities/commits.lua index 9f85640..57e8567 100644 --- a/lua/cmp_git/provider/capabilities/commits.lua +++ b/lua/cmp_git/provider/capabilities/commits.lua @@ -4,13 +4,18 @@ local utils = require("cmp_git.utils") local M = {} +local byte = string.byte local find = string.find +local match = string.match local sub = string.sub local trim_range = utils.trim_range -- The NUL-terminated fields of each commit in the `M.request` format local FIELDS = 6 +-- The bytes of an indentation: tab and space +local INDENT = { [9] = true, [32] = true } + ---@param config cmp_git.Config.GitCommits ---@return cmp_git.CommandSpec function M.request(config) @@ -34,6 +39,53 @@ local function trimmed(s, first, last) return sub(s, trim_range(s, first, last)) end +---The trimmed body without the indentation that all its lines with text share, as `textwrap.dedent` of Python does. +---Some bodies are indented as a whole, and the trim removes the indentation of the first line only. Lines with only +---whitespace do not count, and they become empty when an indentation is removed. A tab and a space do not match. +---Without a shared indentation, the result is the trimmed body. +---@param s string +---@param first integer +---@param last integer +---@return string +local function dedented(s, first, last) + local field_first = first + first, last = trim_range(s, first, last) + if first > last then + return "" + end + + local line_first = first + while line_first > field_first and INDENT[byte(s, line_first - 1)] do + line_first = line_first - 1 + end + -- The first line has text, so the shared indentation is a part of its indentation + if line_first == first then + return sub(s, first, last) + end + + local lines = vim.split(sub(s, line_first, last), "\n", { plain = true }) + local margin = sub(s, line_first, first - 1) + for _, line in ipairs(lines) do + if find(line, "%S") then + local indent = match(line, "^[ \t]*") + local shared = 0 + while shared < #margin and byte(margin, shared + 1) == byte(indent, shared + 1) do + shared = shared + 1 + end + if shared == 0 then + return sub(s, first, last) + end + margin = sub(margin, 1, shared) + end + end + + local width = #margin + for i, line in ipairs(lines) do + lines[i] = find(line, "%S") and sub(line, width + 1) or "" + end + return table.concat(lines, "\n") +end + ---@param result string ---@param config cmp_git.Config.GitCommits ---@return cmp_git.Commit[] @@ -42,7 +94,7 @@ function M.parse(result, config) local now = os.time() local sha_length = config.sha_length -- The position of the NUL after each field of the current commit. The fields are read in place, so that each - -- field is copied once, after the trim. + -- field is copied once, after the trim. `dedented` also splits a body whose first line is indented into lines. local stops = {} local start = 1 @@ -73,7 +125,7 @@ function M.parse(result, config) local commit = { sha = sha, title = trimmed(result, stops[1] + 1, stops[2] - 1), - description = trimmed(result, stops[2] + 1, stops[3] - 1), + description = dedented(result, stops[2] + 1, stops[3] - 1), author_name = sub(result, stops[3] + 1, stops[4] - 1), author_mail = sub(result, stops[4] + 1, stops[5] - 1), commit_timestamp = timestamp, diff --git a/tests/provider/capabilities/test_commits.lua b/tests/provider/capabilities/test_commits.lua index 96a255d..881978b 100644 --- a/tests/provider/capabilities/test_commits.lua +++ b/tests/provider/capabilities/test_commits.lua @@ -73,11 +73,67 @@ T["commit_capability_parse_marker_collision_and_malformed_records"] = function() eq(commits[1].description, "body with ###CMP_GIT_END### marker", "commit parser allows old entry marker text") end --- The parser before it read the fields in place. The test below checks that the new parser gives the same output. +T["commit_capability_parse_dedents_the_body"] = function() + local function description(body) + local raw = commit_record("abcdef1234567890", "subject", body, "Ada", "ada@example.com", 1234567890) + return commit_capability.parse(raw, commit_test_config())[1].description + end + + eq( + description(" ## what\n - a\n\n ## how\n - enable it if\n it is on PATH\n"), + "## what\n- a\n\n## how\n- enable it if\n it is on PATH", + "a body that is indented as a whole keeps its relative indentation" + ) + eq(description(" first\n second"), "first\n second", "the first line has the shared indentation") + eq(description(" first\n second"), " first\nsecond", "a later line has the shared indentation") + eq(description("\t\tfirst\n\t\t\tsecond"), "first\n\tsecond", "tabs are an indentation") + eq(description("first\n second"), "first\n second", "a first line without indentation") + eq(description(" first\nsecond"), "first\nsecond", "a later line without indentation") + eq(description("\tfirst\n second"), "first\n second", "a tab and a space do not match") + eq(description(" first\n \t \n\n second"), "first\n\n\nsecond", "lines with only whitespace become empty") + eq( + description("first\n \t \n second"), + "first\n \t \n second", + "lines with only whitespace stay without a dedent" + ) + eq(description("\n \n\t\n first\n second\n\n"), "first\nsecond", "blank lines around the body are removed") + eq(description(" \r first\n second"), "first\n second", "the indentation starts after other whitespace") + eq(description(""), "", "an empty body") + eq(description(" \n\t "), "", "a body with only whitespace") +end + +-- The parser before it read the fields in place, with the dedent of the body. The test below checks that the new +-- parser gives the same output. local function old_trim(s) return (s:gsub("^%s*(.-)%s*$", "%1")) end +---The body without the indentation that all its lines with text share, written with line lists instead of the index +---arithmetic of the parser. Without a shared indentation, it is the trimmed body. +local function old_body(s) + local trimmed = old_trim(s) + -- The spaces and tabs right before the first byte that is not whitespace + local indent = s:match("([ \t]*)%S") or "" + local lines = vim.split(indent .. trimmed, "\n", { plain = true }) + local margin + for _, line in ipairs(lines) do + if line:find("%S") then + local lead = line:match("^[ \t]*") + margin = margin or lead + while lead:sub(1, #margin) ~= margin do + margin = margin:sub(1, -2) + end + end + end + if not margin or margin == "" then + return trimmed + end + for i, line in ipairs(lines) do + lines[i] = line:find("%S") and line:sub(#margin + 1) or "" + end + return table.concat(lines, "\n") +end + local function old_split_nul(input) local fields = {} local start = 1 @@ -102,7 +158,7 @@ local function old_parse(result, config) table.insert(commits, { sha = old_trim(fields[i]):sub(1, config.sha_length), title = old_trim(fields[i + 1]), - description = old_trim(fields[i + 2]), + description = old_body(fields[i + 2]), author_name = fields[i + 3] or "", author_mail = fields[i + 4] or "", commit_timestamp = timestamp, @@ -146,6 +202,11 @@ T["commit_capability_parse_matches_the_old_parser"] = function() "Ünïcödé", "\031", "a" .. string.rep(" ", 50) .. "b", + " ## what\n - a\n \t \n b\n", + " indented\n", + "\n - after a blank line\n more\n", + "\t- tab line\n", + " \n", } local function text() local parts = {} diff --git a/tests/sources/test_git.lua b/tests/sources/test_git.lua index af9e86c..8d9619c 100644 --- a/tests/sources/test_git.lua +++ b/tests/sources/test_git.lua @@ -86,6 +86,60 @@ 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_commit_documentation_keeps_the_body_layout"] = function() + if vim.fn.executable("git") ~= 1 then + helpers.skip("git is not installed") + end + + -- The body of issue #54. It is indented as a whole, and one line is indented more. + local repo = helpers.git_repo() + repo.commit(table.concat({ + "chore(astronvim): auto install Treesitter language packs", + "", + " ## what", + " - auto install Treesitter language packs", + "", + " ## how", + " - enable config `auto_install` if `tree-sitter` is installed and", + " available on PATH", + }, "\n")) + vim.fn.chdir(repo.dir) + + local git = Git.new(config.normalize({}).git) + local line = "See :" + local context = { + bufnr = helpers.buffer({ "feat: subject", "", line }), + cursor = { row = 3, character = #line }, + line = line, + } + local lists = {} + git:get_commits(function(list) + table.insert(lists, list) + end, context, ":") + helpers.wait_for(function() + return #lists > 0 + end, "commits call back") + + eq(#lists, 1, "commits call back once") + eq(#lists[1].items, 1, "commits give the commit") + local item = lists[1].items[1] + eq(item.documentation, { + kind = "markdown", + value = table.concat({ + "# chore(astronvim): auto install Treesitter language packs", + "", + "## what", + "- auto install Treesitter language packs", + "", + "## how", + "- enable config `auto_install` if `tree-sitter` is installed and", + " available on PATH", + "", + "Authored by Ada Lovelace (ada@example.com), committed on " .. os.date("%c", item.data.commit_timestamp), + }, "\n"), + }, "the documentation keeps the lines of the body without their shared indentation") +end + T["git_requests_answer_without_git"] = function() -- `command.build` gives no job when git is not installed helpers.stub(vim.fn, "executable", function()