From b651d849af6320d706011f0a9cd7b50359be575a Mon Sep 17 00:00:00 2001 From: Peter Tri Ho Date: Mon, 2 Nov 2026 20:18:51 +1100 Subject: [PATCH] fix: remove the shared indentation of the commit body Issue #54 showed the body of a commit on one line in the preview. The plenary job gave the lines of `git log`, and the parser joined them without a separator. 4fe8024 replaced plenary with vim.system, so the parser now reads the full output and keeps the newlines. The parser still trimmed the whole body, so the first line lost its indentation and the other lines kept theirs. A body that is indented as a whole, as in the issue, showed `## what` and then ` ## how`. The parser now removes the indentation that all lines with text share, as textwrap.dedent of Python does. Lines with only whitespace do not count, and they become empty. A tab and a space do not match. A body without a shared indentation is trimmed as before. When the first line is not indented, the parser takes the old path without a copy or a split. A test runs git with the body of the issue and checks the documentation of the item. The fuzz test of the parser now also checks the dedent against a reference that uses line lists. Fixes #54. --- docs/configuration.md | 2 + lua/cmp_git/provider/capabilities/commits.lua | 56 +++++++++++++++- tests/provider/capabilities/test_commits.lua | 65 ++++++++++++++++++- tests/sources/test_git.lua | 54 +++++++++++++++ 4 files changed, 173 insertions(+), 4 deletions(-) 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()