Common library code for other vc*.nvim projects.

Add patch handling code.

+584 -3
+189
lua/vclib/patch.lua
··· 1 + --- Utilities for working with git-format patches. 2 + local 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. 22 + function 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 } 111 + end 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. 116 + function 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 } 147 + end 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. 153 + function 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 187 + end 188 + 189 + return M
+38 -3
lua/vclib/testing.lua
··· 1 1 local M = {} 2 2 3 + --- Helper to parse multiline strings into lines, stripping common indentation. 4 + ---@param s string 5 + ---@return string[] 6 + function M.dedent_into_lines(s) 7 + local l = 1 8 + while s:sub(l, l) == "\n" do 9 + l = l + 1 10 + end 11 + local r = #s 12 + while true do 13 + local c = s:sub(r, r) 14 + if c ~= "\n" and c ~= " " then 15 + break 16 + end 17 + r = r - 1 18 + end 19 + local stripped = s:sub(l, r) 20 + local lines = vim.split(stripped, "\n", { plain = true }) 21 + local min_indent = math.huge 22 + for _, line in ipairs(lines) do 23 + local indent = #line - #line:gsub("^%s*", "") 24 + if #line > 0 and indent < min_indent then 25 + min_indent = indent 26 + end 27 + end 28 + if min_indent == math.huge then 29 + min_indent = 0 30 + end 31 + for i, line in ipairs(lines) do 32 + lines[i] = line:sub(min_indent + 1) 33 + end 34 + return lines 35 + end 36 + 3 37 local function _run_test_suite(suite_name, test_suite) 4 38 local suite_failed = 0 5 39 local suite_total = 0 ··· 59 93 end 60 94 end 61 95 62 - function M.assert_list_eq(actual, expected) 96 + function M.assert_list_eq(actual, expected, msg_prefix) 97 + msg_prefix = msg_prefix or "" 63 98 assert( 64 99 #actual == #expected, 65 - string.format("Lists have different lengths: %d vs %d", #actual, #expected) 100 + string.format(msg_prefix .. "Lists have different lengths: %d vs %d", #actual, #expected) 66 101 ) 67 102 local diff = "" 68 103 for i = 1, #expected do ··· 72 107 end 73 108 end 74 109 if diff ~= "" then 75 - error("Lists differ:" .. diff) 110 + error(msg_prefix .. "Lists differ:" .. diff) 76 111 end 77 112 end 78 113
+1
lua/vclib_tests/init.lua
··· 5 5 function M.run() 6 6 local test_modules = { 7 7 "vclib_tests.fold", 8 + "vclib_tests.patch", 8 9 } 9 10 testing.run_tests(test_modules) 10 11 end
+356
lua/vclib_tests/patch.lua
··· 1 + local M = {} 2 + 3 + local patch = require "vclib.patch" 4 + local testing = require "vclib.testing" 5 + 6 + -- Helper function to create a git diff output. 7 + local 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") 18 + end 19 + 20 + -- Helper to compare patch structures. 21 + local 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 104 + end 105 + 106 + M.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 + 215 + M.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 + 356 + return M