fix(diff): improved diff parsing. Closes #2424. Closes #2420

This commit is contained in:
Folke Lemaitre 2025-11-03 12:43:01 +01:00
parent 8bb3ad6c53
commit b6e4eb7e60
No known key found for this signature in database
GPG key ID: 9B52594D560070AB
2 changed files with 194 additions and 26 deletions

View file

@ -10,7 +10,10 @@ local M = {}
---@field line number
---@class snacks.picker.diff.Block
---@field type? "new"|"delete"|"rename"|"copy"|"mode"
---@field file string
---@field left? string
---@field right? string
---@field header string[]
---@field hunks snacks.picker.diff.Hunk[]
@ -86,17 +89,88 @@ function M.parse(lines)
local block ---@type snacks.picker.diff.Block?
local ret = {} ---@type snacks.picker.diff.Block[]
---@param file? string
---@return string?
local function norm(file)
if file then
file = file:gsub("\t.*$", "") -- remove tab and after
file = file:gsub('^"(.-)"$', "%1") -- remove quotes
if file == "/dev/null" then -- no file
return
end
local prefix = { "a", "b", "i", "w", "c", "o", "old", "new" }
for _, s in ipairs(prefix) do -- remove prefixes
if file:sub(1, #s + 1) == s .. "/" then
return file:sub(#s + 2)
end
end
return file
end
end
local function emit()
if block and hunk then
hunk = nil
elseif not block then
return
end
if block then
table.sort(block.hunks, function(a, b)
return a.line < b.line
end)
ret[#ret + 1] = block
block = nil
for _, line in ipairs(block.header) do
if line:find("^%-%-%- ") then
block.left = norm(line:sub(5))
elseif line:find("^%+%+%+ ") then
block.right = norm(line:sub(5))
elseif line:find("^rename from") then
block.type = "rename"
block.left = norm(line:match("^rename from (.*)"))
elseif line:find("^rename to") then
block.type = "rename"
block.right = norm(line:match("^rename to (.*)"))
elseif line:find("^copy from") then
block.type = "copy"
block.left = norm(line:match("^copy from (.*)"))
elseif line:find("^copy to") then
block.type = "copy"
block.right = norm(line:match("^copy to (.*)"))
elseif line:find("^new file mode") then
block.type = "new"
elseif line:find("^deleted file mode") then
block.type = "delete"
elseif line:find("^old mode") or line:find("^new mode") then
block.type = "mode"
end
end
local first = block.header[1] or ""
if not block.right and not block.left and first:find("^diff") then
-- no left/right so for sure no rename.
-- this means the diff header is for the same file
if first:find("^diff %-%-cc") then
block.left = norm(first:match("^diff %-%-cc (.+)$"))
block.right = block.left
else
first = first:gsub("^diff ", ""):gsub("^%s*%-%S+%s*", "") --[[@as string]]
local idx = 1
while idx <= #first do
local s = first:find(" ", idx, true)
if not s then
break
end
idx = s + 1
local l = norm(first:sub(1, s - 1))
local r = norm(first:sub(s + 1))
if l == r then
block.left = l
block.right = r
break
end
end
end
end
block.file = block.right or block.left or block.file
table.sort(block.hunks, function(a, b)
return a.line < b.line
end)
ret[#ret + 1] = block
block = nil
end
local with_diff_header = vim.trim(table.concat(lines, "\n")):find("^diff") ~= nil
@ -106,15 +180,8 @@ function M.parse(lines)
-- Ignore empty lines before a diff block
elseif text:find("^diff") or (not with_diff_header and text:find("^%-%-%- ") and (not block or hunk)) then
emit()
local file ---@type string?
if text:find("^diff") then
file = text:gsub("^diff%s*", ""):gsub("^%-%S+%s*", "")
file = file:match('^"%a/(.-)"') or file:match("^%a/(.-) %a/") or file:match("^%a/(.*)$") or file
elseif text:find("^%-%-%-") then
file = text:match("^%-%-%- %a/([^\t]+)") or text:match("^%-%-%- ([^\t]+)")
end
block = {
file = file or "unknown",
file = "", --file or "unknown",
header = { text },
hunks = {},
}
@ -135,7 +202,6 @@ function M.parse(lines)
-- Hunk body
hunk.diff[#hunk.diff + 1] = text
elseif block then
-- File header
block.header[#block.header + 1] = text
else
Snacks.notify.error("unexpected line: " .. text, { title = "Snacks Picker Diff" })

View file

@ -23,6 +23,29 @@ describe("picker.diff", function()
assert.equals(4, #blocks[1].hunks[1].diff)
end)
it("doesn't parse a filename from deleted lua comment", function()
local lines = {
"diff --git a/lua/todo-comments/config.lua b/lua/todo-comments/config.lua",
"index 0e2d34e..a8e1077 100644",
"--- a/lua/todo-comments/config.lua",
"+++ b/lua/todo-comments/config.lua",
"@@ -11,7 +11,6 @@ M.loaded = false",
' M.ns = vim.api.nvim_create_namespace("todo-comments")',
"",
" --- @class TodoOptions",
"--- TODO: add support for markdown todos",
" local defaults = {",
" signs = true, -- show icons in the signs column",
" sign_priority = 8, -- sign priority",
" }",
" end)",
"",
}
local blocks = diff.parse(lines)
assert.equals(1, #blocks)
assert.equals("lua/todo-comments/config.lua", blocks[1].file)
end)
it("parses plain diff format (no git header)", function()
local lines = {
"--- file1.txt\t2024-01-01 12:00:00",
@ -35,12 +58,42 @@ describe("picker.diff", function()
local blocks = diff.parse(lines)
assert.equals(1, #blocks)
assert.equals("file1.txt", blocks[1].file)
assert.equals("file2.txt", blocks[1].file)
assert.equals(2, #blocks[1].header)
assert.equals(1, #blocks[1].hunks)
assert.equals(1, blocks[1].hunks[1].line)
end)
it("parses plain diff format (recursive)", function()
local lines = {
"diff -Naur old/file1.txt new/file1.txt",
"--- old/file1.txt 2025-01-01 13:00:00.000000000 +0100",
"+++ new/file1.txt 1970-01-01 01:00:00.000000000 +0100",
"@@ -1,3 +0,0 @@",
"-context1",
"-old content",
"-context3",
"diff -Naur old/file2.txt new/file2.txt",
"--- old/file2.txt 1970-01-01 01:00:00.000000000 +0100",
"+++ new/file2.txt 2025-01-01 13:00:00.000000000 +0100",
"@@ -0,0 +1,3 @@",
"+context1",
"+new line",
"+context3",
}
local blocks = diff.parse(lines)
assert.equals(2, #blocks)
assert.equals(3, #blocks[1].header)
assert.equals("file1.txt", blocks[1].file)
assert.equals(1, #blocks[1].hunks)
assert.equals(0, blocks[1].hunks[1].line)
assert.equals(3, #blocks[2].header)
assert.equals("file2.txt", blocks[2].file)
assert.equals(1, #blocks[2].hunks)
assert.equals(1, blocks[2].hunks[1].line)
end)
it("parses combined diff format (merge commits)", function()
local lines = {
"diff --cc file.txt",
@ -134,6 +187,20 @@ describe("picker.diff", function()
assert.equals(0, #blocks[1].hunks) -- no hunks for binary
end)
it("handles binary files with prefixes in the path", function()
local lines = {
"diff --git a/ b/image.png b/ b/image.png",
"index abc123..def456 100644",
"Binary files a/image.png and b/image.png differ",
}
local blocks = diff.parse(lines)
assert.equals(1, #blocks)
assert.equals(" b/image.png", blocks[1].file)
assert.equals(3, #blocks[1].header) -- diff line + binary notice
assert.equals(0, #blocks[1].hunks) -- no hunks for binary
end)
it("handles pure renames", function()
local lines = {
"diff --git a/old.txt b/new.txt",
@ -144,11 +211,33 @@ describe("picker.diff", function()
local blocks = diff.parse(lines)
assert.equals(1, #blocks)
assert.equals("old.txt", blocks[1].file)
assert.equals("new.txt", blocks[1].file)
assert.equals(4, #blocks[1].header)
assert.equals(0, #blocks[1].hunks)
end)
it("handles renames with a diff", function()
local lines = {
"diff --git a/old.txt b/new.txt",
"similarity index 66%",
"rename from old.txt",
"rename to new.txt",
"--- a/old.text",
"+++ b/new.txt",
"@@ -1,3 +1,3 @@",
"-line0",
" line1",
" line2",
"+line3",
}
local blocks = diff.parse(lines)
assert.equals(1, #blocks)
assert.equals("new.txt", blocks[1].file)
assert.equals(6, #blocks[1].header)
assert.equals(1, #blocks[1].hunks)
end)
it("handles mode changes", function()
local lines = {
"diff --git a/script.sh b/script.sh",
@ -222,9 +311,9 @@ describe("picker.diff", function()
it("handles files with spaces in name", function()
local lines = {
'diff --git "a/my file.txt" b/my file.txt',
"--- a/my file.txt",
"+++ b/my file.txt",
"diff --git a/dir c/my file.txt b/dir c/my file.txt",
"--- a/dir c/my file.txt",
"+++ b/dir c/my file.txt",
"@@ -1,1 +1,1 @@",
"-old",
"+new",
@ -232,14 +321,14 @@ describe("picker.diff", function()
local blocks = diff.parse(lines)
assert.equals(1, #blocks)
assert.equals("my file.txt", blocks[1].file)
assert.equals("dir c/my file.txt", blocks[1].file)
end)
it("handles files with spaces in name without quotes", function()
it("handles quoted filenames", function()
local lines = {
"diff --git a/my file.txt b/my file.txt",
"--- a/my file.txt",
"+++ b/my file.txt",
'diff --git "a/my file.txt" "b/my file.txt"',
'--- "a/my file.txt"',
'+++ "b/my file.txt"',
"@@ -1,1 +1,1 @@",
"-old",
"+new",
@ -409,12 +498,25 @@ describe("picker.diff", function()
"@@ -1,1 +1,1 @@",
"-old",
"+new",
"--- plain2.txt",
"+++ plain2.txt",
"@@ -1,1 +1,1 @@",
"-old",
"+new",
"diff --git a/git2.txt b/git2.txt",
"--- a/git2.txt",
"+++ b/git2.txt",
"@@ -1,1 +1,1 @@",
"-old",
"+new",
}
local blocks = diff.parse(lines)
assert.equals(2, #blocks)
assert.equals(4, #blocks)
assert.equals("plain1.txt", blocks[1].file)
assert.equals("git1.txt", blocks[2].file)
assert.equals("plain2.txt", blocks[3].file)
assert.equals("git2.txt", blocks[4].file)
end)
it("handles symlink changes", function()