Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
56 changes: 54 additions & 2 deletions lua/cmp_git/provider/capabilities/commits.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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[]
Expand All @@ -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

Expand Down Expand Up @@ -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,
Expand Down
65 changes: 63 additions & 2 deletions tests/provider/capabilities/test_commits.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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 = {}
Expand Down
54 changes: 54 additions & 0 deletions tests/sources/test_git.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading