Common library code for other vc*.nvim projects.
1--- Utilities for working with git-format patches.
2local M = {}
3
4---@class PatchLine
5---@field type "context"|"add"|"remove" The type of line in the patch.
6---@field content string The actual line content (without the leading +/- marker).
7
8---@class Hunk
9---@field old_start integer Starting line number in the old file.
10---@field old_count integer Number of lines in the old file.
11---@field new_start integer Starting line number in the new file.
12---@field new_count integer Number of lines in the new file.
13---@field lines PatchLine[] The lines in this hunk.
14
15---@class Patch
16---@field hunks Hunk[] The hunks in this patch.
17
18--- Parse a single file git-format diff into a structured patch.
19--- (single file to keep things simple, and was sufficient for our use cases)
20---@param patch_text string The git diff output.
21---@return Patch|nil The parsed patch, or nil if no hunks found.
22function M.parse_single_file_patch(patch_text)
23 local lines = vim.split(patch_text, "\n", { plain = true })
24
25 -- Skip metadata until we hit the first @@.
26 local i = 1
27 while i <= #lines and not lines[i]:match "^@@" do
28 i = i + 1
29 end
30
31 if i > #lines then
32 -- No hunks found.
33 return nil
34 end
35
36 local hunks = {}
37 local current_hunk = nil
38
39 while i <= #lines do
40 local line = lines[i]
41
42 if line:match "^@@" then
43 -- Save previous hunk if any.
44 if current_hunk then
45 table.insert(hunks, current_hunk)
46 end
47
48 -- Parse hunk header: @@ -old_start,old_count +new_start,new_count @@.
49 local old_start, old_count, new_start, new_count =
50 line:match "^@@ %-(%d+),?(%d*) %+(%d+),?(%d*) @@"
51
52 if old_start and new_start then
53 current_hunk = {
54 old_start = tonumber(old_start),
55 old_count = tonumber(old_count) or 1,
56 new_start = tonumber(new_start),
57 new_count = tonumber(new_count) or 1,
58 lines = {},
59 }
60 end
61 i = i + 1
62 else
63 local first_char = line:sub(1, 1)
64 if first_char == " " then
65 -- Context line.
66 if current_hunk then
67 table.insert(current_hunk.lines, {
68 type = "context",
69 content = line:sub(2),
70 })
71 end
72 i = i + 1
73 elseif first_char == "-" then
74 -- Removed line.
75 if current_hunk then
76 table.insert(current_hunk.lines, {
77 type = "remove",
78 content = line:sub(2),
79 })
80 end
81 i = i + 1
82 elseif first_char == "+" then
83 -- Added line.
84 if current_hunk then
85 table.insert(current_hunk.lines, {
86 type = "add",
87 content = line:sub(2),
88 })
89 end
90 i = i + 1
91 elseif first_char == "\\" then
92 -- "\ No newline at end of file" marker - ignore for now.
93 i = i + 1
94 else
95 -- End of hunks or unknown line.
96 break
97 end
98 end
99 end
100
101 -- Save last hunk.
102 if current_hunk then
103 table.insert(hunks, current_hunk)
104 end
105
106 if #hunks == 0 then
107 return nil
108 end
109
110 return { hunks = hunks }
111end
112
113--- Invert a patch (swap add/remove operations and old/new positions).
114---@param patch Patch The patch to invert.
115---@return Patch The inverted patch.
116function M.invert_patch(patch)
117 local inverted_hunks = {}
118
119 for _, hunk in ipairs(patch.hunks) do
120 local inverted_lines = {}
121
122 for _, line in ipairs(hunk.lines) do
123 local inverted_type = line.type
124 if line.type == "add" then
125 inverted_type = "remove"
126 elseif line.type == "remove" then
127 inverted_type = "add"
128 end
129
130 table.insert(inverted_lines, {
131 type = inverted_type,
132 content = line.content,
133 })
134 end
135
136 -- Swap old and new positions.
137 table.insert(inverted_hunks, {
138 old_start = hunk.new_start,
139 old_count = hunk.new_count,
140 new_start = hunk.old_start,
141 new_count = hunk.old_count,
142 lines = inverted_lines,
143 })
144 end
145
146 return { hunks = inverted_hunks }
147end
148
149--- Apply a patch to file contents.
150---@param file_lines string[] The current file contents.
151---@param patch Patch The patch to apply.
152---@return string[] The file contents after applying the patch.
153function M.apply_patch(file_lines, patch)
154 local result = {}
155 local i = 1
156
157 for _, hunk in ipairs(patch.hunks) do
158 -- Copy unchanged lines before this hunk.
159 while i < hunk.old_start do
160 table.insert(result, file_lines[i])
161 i = i + 1
162 end
163
164 -- Process hunk lines.
165 for _, line in ipairs(hunk.lines) do
166 if line.type == "context" then
167 -- Context line - should match current file.
168 table.insert(result, line.content)
169 i = i + 1
170 elseif line.type == "remove" then
171 -- Line removed - skip it in the result, advance in current file.
172 i = i + 1
173 elseif line.type == "add" then
174 -- Line added - add to result, don't advance in current file.
175 table.insert(result, line.content)
176 end
177 end
178 end
179
180 -- Copy any remaining unchanged lines.
181 while i <= #file_lines do
182 table.insert(result, file_lines[i])
183 i = i + 1
184 end
185
186 return result
187end
188
189return M