Common library code for other vc*.nvim projects.
1local M = {}
2
3local patch = require "vclib.patch"
4local testing = require "vclib.testing"
5
6-- Helper function to create a git diff output.
7local function make_git_diff_from_string(hunks)
8 local lines = {
9 "diff --git a/test.txt b/test.txt",
10 "index abc123..def456 100644",
11 "--- a/test.txt",
12 "+++ b/test.txt",
13 }
14 for _, line in ipairs(testing.dedent_into_lines(hunks)) do
15 lines[#lines + 1] = line
16 end
17 return table.concat(lines, "\n")
18end
19
20-- Helper to compare patch structures.
21local function assert_patch_eq(actual, expected)
22 assert(actual ~= nil, "actual patch is nil")
23 assert(expected ~= nil, "expected patch is nil")
24 assert(
25 #actual.hunks == #expected.hunks,
26 string.format("Expected %d hunks, got %d", #expected.hunks, #actual.hunks)
27 )
28
29 for h = 1, #expected.hunks do
30 local ah = actual.hunks[h]
31 local eh = expected.hunks[h]
32
33 assert(
34 ah.old_start == eh.old_start,
35 string.format(
36 "Hunk %d: old_start mismatch: %d vs %d",
37 h,
38 ah.old_start,
39 eh.old_start
40 )
41 )
42 assert(
43 ah.old_count == eh.old_count,
44 string.format(
45 "Hunk %d: old_count mismatch: %d vs %d",
46 h,
47 ah.old_count,
48 eh.old_count
49 )
50 )
51 assert(
52 ah.new_start == eh.new_start,
53 string.format(
54 "Hunk %d: new_start mismatch: %d vs %d",
55 h,
56 ah.new_start,
57 eh.new_start
58 )
59 )
60 assert(
61 ah.new_count == eh.new_count,
62 string.format(
63 "Hunk %d: new_count mismatch: %d vs %d",
64 h,
65 ah.new_count,
66 eh.new_count
67 )
68 )
69 assert(
70 #ah.lines == #eh.lines,
71 string.format(
72 "Hunk %d: Expected %d lines, got %d",
73 h,
74 #eh.lines,
75 #ah.lines
76 )
77 )
78
79 for l = 1, #eh.lines do
80 local al = ah.lines[l]
81 local el = eh.lines[l]
82 assert(
83 al.type == el.type,
84 string.format(
85 "Hunk %d, line %d: type mismatch: %s vs %s",
86 h,
87 l,
88 al.type,
89 el.type
90 )
91 )
92 assert(
93 al.content == el.content,
94 string.format(
95 "Hunk %d, line %d: content mismatch: '%s' vs '%s'",
96 h,
97 l,
98 al.content,
99 el.content
100 )
101 )
102 end
103 end
104end
105
106M.parse_single_file_patch = {
107 test_cases = {
108 single_hunk = {
109 patch_text = make_git_diff_from_string [[
110 @@ -1,3 +1,4 @@
111 line1
112 line2
113 +NEW LINE
114 line3
115 ]],
116 expected = {
117 hunks = {
118 {
119 old_start = 1,
120 old_count = 3,
121 new_start = 1,
122 new_count = 4,
123 lines = {
124 { type = "context", content = "line1" },
125 { type = "context", content = "line2" },
126 { type = "add", content = "NEW LINE" },
127 { type = "context", content = "line3" },
128 },
129 },
130 },
131 },
132 },
133 multiple_hunks = {
134 patch_text = make_git_diff_from_string [[
135 @@ -1,2 +1,3 @@
136 line1
137 +NEW1
138 line2
139 @@ -5,2 +6,3 @@
140 line5
141 +NEW2
142 line6
143 ]],
144 expected = {
145 hunks = {
146 {
147 old_start = 1,
148 old_count = 2,
149 new_start = 1,
150 new_count = 3,
151 lines = {
152 { type = "context", content = "line1" },
153 { type = "add", content = "NEW1" },
154 { type = "context", content = "line2" },
155 },
156 },
157 {
158 old_start = 5,
159 old_count = 2,
160 new_start = 6,
161 new_count = 3,
162 lines = {
163 { type = "context", content = "line5" },
164 { type = "add", content = "NEW2" },
165 { type = "context", content = "line6" },
166 },
167 },
168 },
169 },
170 },
171 no_hunks = {
172 patch_text = "diff --git a/test.txt b/test.txt\nindex abc123..abc123 100644\n",
173 expected = nil,
174 },
175 file_header_no_hunks = {
176 expected = nil,
177 patch_text = "diff --git a/test.txt b/test.txt\nindex abc123..abc123 100644\n--- a/test.txt\n+++ b/test.txt",
178 },
179 mixed_operations = {
180 patch_text = make_git_diff_from_string [[
181 @@ -1,4 +1,4 @@
182 context1
183 -removed
184 +added
185 context2
186 ]],
187 expected = {
188 hunks = {
189 {
190 old_start = 1,
191 old_count = 4,
192 new_start = 1,
193 new_count = 4,
194 lines = {
195 { type = "context", content = "context1" },
196 { type = "remove", content = "removed" },
197 { type = "add", content = "added" },
198 { type = "context", content = "context2" },
199 },
200 },
201 },
202 },
203 },
204 },
205 test = function(case)
206 local result = patch.parse_single_file_patch(case.patch_text)
207 if case.expected == nil then
208 assert(result == nil, "Expected nil for patch with no hunks")
209 else
210 assert_patch_eq(result, case.expected)
211 end
212 end,
213}
214
215M.roundtrip = {
216 test_cases = {
217 simple_addition = {
218 old_file = { "line1", "line2", "line3" },
219 new_file = { "line1", "line2", "NEW LINE", "line3" },
220 patch_text = make_git_diff_from_string [[
221 @@ -1,3 +1,4 @@
222 line1
223 line2
224 +NEW LINE
225 line3
226 ]],
227 },
228 simple_deletion = {
229 old_file = { "line1", "line2", "line3", "line4" },
230 new_file = { "line1", "line2", "line4" },
231 patch_text = make_git_diff_from_string [[
232 @@ -1,4 +1,3 @@
233 line1
234 line2
235 -line3
236 line4
237 ]],
238 },
239 simple_modification = {
240 old_file = { "line1", "original line", "line3" },
241 new_file = { "line1", "modified line", "line3" },
242 patch_text = make_git_diff_from_string [[
243 @@ -1,3 +1,3 @@
244 line1
245 -original line
246 +modified line
247 line3
248 ]],
249 },
250 multiple_hunks = {
251 old_file = { "line1", "line2", "unchanged", "line3", "line4" },
252 new_file = {
253 "line1",
254 "NEW1",
255 "line2",
256 "unchanged",
257 "line3",
258 "NEW2",
259 "line4",
260 },
261 patch_text = make_git_diff_from_string [[
262 @@ -1,2 +1,3 @@
263 line1
264 +NEW1
265 line2
266 @@ -4,2 +5,3 @@
267 line3
268 +NEW2
269 line4
270 ]],
271 },
272 complex_changes = {
273 old_file = {
274 "header",
275 "old line 1",
276 "old line 2",
277 "more content",
278 "old footer",
279 },
280 new_file = { "header", "new line 1", "content", "more content", "footer" },
281 patch_text = make_git_diff_from_string [[
282 @@ -1,5 +1,5 @@
283 header
284 -old line 1
285 -old line 2
286 +new line 1
287 +content
288 more content
289 -old footer
290 +footer
291 ]],
292 },
293 add_to_empty_file = {
294 old_file = {},
295 new_file = { "line1", "line2" },
296 patch_text = make_git_diff_from_string [[
297 @@ -0,0 +1,2 @@
298 +line1
299 +line2
300 ]],
301 },
302 delete_entire_file = {
303 old_file = { "line1", "line2" },
304 new_file = {},
305 patch_text = make_git_diff_from_string [[
306 @@ -1,2 +0,0 @@
307 -line1
308 -line2
309 ]],
310 },
311 addition_at_beginning = {
312 old_file = { "line1", "line2" },
313 new_file = { "NEW", "line1", "line2" },
314 patch_text = make_git_diff_from_string [[
315 @@ -1,2 +1,3 @@
316 +NEW
317 line1
318 line2
319 ]],
320 },
321 addition_at_end = {
322 old_file = { "line1", "line2" },
323 new_file = { "line1", "line2", "NEW" },
324 patch_text = make_git_diff_from_string [[
325 @@ -1,2 +1,3 @@
326 line1
327 line2
328 +NEW
329 ]],
330 },
331 },
332 test = function(case)
333 -- Validate parsing.
334 local parsed = patch.parse_single_file_patch(case.patch_text)
335 assert(parsed ~= nil, "Failed to parse patch")
336
337 -- Validate patch application.
338 local forward_result = patch.apply_patch(case.old_file, parsed)
339 testing.assert_list_eq(
340 forward_result,
341 case.new_file,
342 "Forward application failed: "
343 )
344
345 -- Validate patch inversion and reverse application.
346 local inverted = patch.invert_patch(parsed)
347 local reverse_result = patch.apply_patch(case.new_file, inverted)
348 testing.assert_list_eq(
349 reverse_result,
350 case.old_file,
351 "Reverse application failed: "
352 )
353 end,
354}
355
356return M