feat: support email-quoted diffs (#149)

## Problem

Email-quoted diffs (`> diff --git ...`, `> @@ ...`) from git-send-email
/ email reply workflows produce 0 hunks because the parser matches
patterns against raw lines containing `> ` quote prefixes. Closes #141.

## Solution

Strip the `> ` quote prefix before pattern matching in the parser. Store
`quote_width` on each hunk. In `highlight.lua`, offset all extmark
column positions by `qw` and expand `pw > 1` guards to `qw > 0 or pw >
1` for DiffsClear suppression. Clamp body prefix DiffsClear `end_col` to
the actual buffer line byte length for bare `>` lines (1-byte buffer
lines where `end_col = pw + qw` would exceed bounds and cause
`nvim_buf_set_extmark` to silently fail inside `pcall`).

15 new specs covering parser detection, stripping, false-positive
rejection, and highlight column offsets including the bare `>` clamp
edge case.
This commit is contained in:
Barrett Ruth 2026-03-05 10:31:19 -05:00 committed by GitHub
parent 70d5bee797
commit e1d3b81607
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 599 additions and 66 deletions

View file

@ -13,6 +13,7 @@
---@field file_new_start integer?
---@field file_new_count integer?
---@field prefix_width integer
---@field quote_width integer
---@field repo_root string?
local M = {}
@ -135,6 +136,17 @@ function M.parse_buffer(bufnr)
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local repo_root = get_repo_root(bufnr)
local quote_prefix = nil
local quote_width = 0
for _, l in ipairs(lines) do
local qp = l:match('^(>+ )diff %-%-') or l:match('^(>+ )@@ %-')
if qp then
quote_prefix = qp
quote_width = #qp
break
end
end
---@type diffs.Hunk[]
local hunks = {}
@ -172,6 +184,7 @@ function M.parse_buffer(bufnr)
local old_remaining = nil
---@type integer?
local new_remaining = nil
local current_quote_width = 0
local function flush_hunk()
if hunk_start and #hunk_lines > 0 then
@ -184,6 +197,7 @@ function M.parse_buffer(bufnr)
header_context_col = hunk_header_context_col,
lines = hunk_lines,
prefix_width = hunk_prefix_width,
quote_width = current_quote_width,
file_old_start = file_old_start,
file_old_count = file_old_count,
file_new_start = file_new_start,
@ -209,19 +223,32 @@ function M.parse_buffer(bufnr)
end
for i, line in ipairs(lines) do
local diff_git_file = line:match('^diff %-%-git a/.+ b/(.+)$')
or line:match('^diff %-%-combined (.+)$')
or line:match('^diff %-%-cc (.+)$')
local neogit_file = line:match('^modified%s+(.+)$')
or (not line:match('^new file mode') and line:match('^new file%s+(.+)$'))
or (not line:match('^deleted file mode') and line:match('^deleted%s+(.+)$'))
or line:match('^renamed%s+(.+)$')
or line:match('^copied%s+(.+)$')
local bare_file = not hunk_start and line:match('^([^%s]+%.[^%s]+)$')
local filename = line:match('^[MADRCU%?!]%s+(.+)$') or diff_git_file or neogit_file or bare_file
local logical = line
if quote_prefix then
if line:sub(1, quote_width) == quote_prefix then
logical = line:sub(quote_width + 1)
elseif line:match('^>+$') then
logical = ''
end
end
local diff_git_file = logical:match('^diff %-%-git a/.+ b/(.+)$')
or logical:match('^diff %-%-combined (.+)$')
or logical:match('^diff %-%-cc (.+)$')
local neogit_file = logical:match('^modified%s+(.+)$')
or (not logical:match('^new file mode') and logical:match('^new file%s+(.+)$'))
or (not logical:match('^deleted file mode') and logical:match('^deleted%s+(.+)$'))
or logical:match('^renamed%s+(.+)$')
or logical:match('^copied%s+(.+)$')
local bare_file = not hunk_start and logical:match('^([^%s]+%.[^%s]+)$')
local filename = logical:match('^[MADRCU%?!]%s+(.+)$')
or diff_git_file
or neogit_file
or bare_file
if filename then
flush_hunk()
current_filename = filename
current_quote_width = (logical ~= line) and quote_width or 0
local cache_key = (repo_root or '') .. '\0' .. filename
local cached = ft_lang_cache[cache_key]
if cached then
@ -243,13 +270,13 @@ function M.parse_buffer(bufnr)
hunk_prefix_width = 1
header_start = i
header_lines = {}
elseif line:match('^@@+') then
elseif logical:match('^@@+') then
flush_hunk()
hunk_start = i
local at_prefix = line:match('^(@@+)')
local at_prefix = logical:match('^(@@+)')
hunk_prefix_width = #at_prefix - 1
if #at_prefix == 2 then
local hs, hc, hs2, hc2 = line:match('^@@ %-(%d+),?(%d*) %+(%d+),?(%d*) @@')
local hs, hc, hs2, hc2 = logical:match('^@@ %-(%d+),?(%d*) %+(%d+),?(%d*) @@')
if hs then
file_old_start = tonumber(hs)
file_old_count = tonumber(hc) or 1
@ -259,31 +286,31 @@ function M.parse_buffer(bufnr)
new_remaining = file_new_count
end
else
local hs, hc = line:match('%-(%d+),?(%d*)')
local hs, hc = logical:match('%-(%d+),?(%d*)')
if hs then
file_old_start = tonumber(hs)
file_old_count = tonumber(hc) or 1
old_remaining = file_old_count
end
local hs2, hc2 = line:match('%+(%d+),?(%d*) @@')
local hs2, hc2 = logical:match('%+(%d+),?(%d*) @@')
if hs2 then
file_new_start = tonumber(hs2)
file_new_count = tonumber(hc2) or 1
new_remaining = file_new_count
end
end
local at_end, context = line:match('^(@@+.-@@+%s*)(.*)')
local at_end, context = logical:match('^(@@+.-@@+%s*)(.*)')
if context and context ~= '' then
hunk_header_context = context
hunk_header_context_col = #at_end
hunk_header_context_col = #at_end + current_quote_width
end
if hunk_count then
hunk_count = hunk_count + 1
end
elseif hunk_start then
local prefix = line:sub(1, 1)
local prefix = logical:sub(1, 1)
if prefix == ' ' or prefix == '+' or prefix == '-' then
table.insert(hunk_lines, line)
table.insert(hunk_lines, logical)
if old_remaining and (prefix == ' ' or prefix == '-') then
old_remaining = old_remaining - 1
end
@ -291,7 +318,7 @@ function M.parse_buffer(bufnr)
new_remaining = new_remaining - 1
end
elseif
line == ''
logical == ''
and old_remaining
and old_remaining > 0
and new_remaining
@ -301,11 +328,11 @@ function M.parse_buffer(bufnr)
old_remaining = old_remaining - 1
new_remaining = new_remaining - 1
elseif
line == ''
or line:match('^[MADRC%?!]%s+')
or line:match('^diff ')
or line:match('^index ')
or line:match('^Binary ')
logical == ''
or logical:match('^[MADRC%?!]%s+')
or logical:match('^diff ')
or logical:match('^index ')
or logical:match('^Binary ')
then
flush_hunk()
current_filename = nil
@ -315,7 +342,7 @@ function M.parse_buffer(bufnr)
end
end
if header_start and not hunk_start then
table.insert(header_lines, line)
table.insert(header_lines, logical)
end
end