···11+--- Utilities for working with git-format patches.
22+local M = {}
33+44+---@class PatchLine
55+---@field type "context"|"add"|"remove" The type of line in the patch.
66+---@field content string The actual line content (without the leading +/- marker).
77+88+---@class Hunk
99+---@field old_start integer Starting line number in the old file.
1010+---@field old_count integer Number of lines in the old file.
1111+---@field new_start integer Starting line number in the new file.
1212+---@field new_count integer Number of lines in the new file.
1313+---@field lines PatchLine[] The lines in this hunk.
1414+1515+---@class Patch
1616+---@field hunks Hunk[] The hunks in this patch.
1717+1818+--- Parse a single file git-format diff into a structured patch.
1919+--- (single file to keep things simple, and was sufficient for our use cases)
2020+---@param patch_text string The git diff output.
2121+---@return Patch|nil The parsed patch, or nil if no hunks found.
2222+function M.parse_single_file_patch(patch_text)
2323+ local lines = vim.split(patch_text, "\n", { plain = true })
2424+2525+ -- Skip metadata until we hit the first @@.
2626+ local i = 1
2727+ while i <= #lines and not lines[i]:match "^@@" do
2828+ i = i + 1
2929+ end
3030+3131+ if i > #lines then
3232+ -- No hunks found.
3333+ return nil
3434+ end
3535+3636+ local hunks = {}
3737+ local current_hunk = nil
3838+3939+ while i <= #lines do
4040+ local line = lines[i]
4141+4242+ if line:match "^@@" then
4343+ -- Save previous hunk if any.
4444+ if current_hunk then
4545+ table.insert(hunks, current_hunk)
4646+ end
4747+4848+ -- Parse hunk header: @@ -old_start,old_count +new_start,new_count @@.
4949+ local old_start, old_count, new_start, new_count =
5050+ line:match "^@@ %-(%d+),?(%d*) %+(%d+),?(%d*) @@"
5151+5252+ if old_start and new_start then
5353+ current_hunk = {
5454+ old_start = tonumber(old_start),
5555+ old_count = tonumber(old_count) or 1,
5656+ new_start = tonumber(new_start),
5757+ new_count = tonumber(new_count) or 1,
5858+ lines = {},
5959+ }
6060+ end
6161+ i = i + 1
6262+ else
6363+ local first_char = line:sub(1, 1)
6464+ if first_char == " " then
6565+ -- Context line.
6666+ if current_hunk then
6767+ table.insert(current_hunk.lines, {
6868+ type = "context",
6969+ content = line:sub(2),
7070+ })
7171+ end
7272+ i = i + 1
7373+ elseif first_char == "-" then
7474+ -- Removed line.
7575+ if current_hunk then
7676+ table.insert(current_hunk.lines, {
7777+ type = "remove",
7878+ content = line:sub(2),
7979+ })
8080+ end
8181+ i = i + 1
8282+ elseif first_char == "+" then
8383+ -- Added line.
8484+ if current_hunk then
8585+ table.insert(current_hunk.lines, {
8686+ type = "add",
8787+ content = line:sub(2),
8888+ })
8989+ end
9090+ i = i + 1
9191+ elseif first_char == "\\" then
9292+ -- "\ No newline at end of file" marker - ignore for now.
9393+ i = i + 1
9494+ else
9595+ -- End of hunks or unknown line.
9696+ break
9797+ end
9898+ end
9999+ end
100100+101101+ -- Save last hunk.
102102+ if current_hunk then
103103+ table.insert(hunks, current_hunk)
104104+ end
105105+106106+ if #hunks == 0 then
107107+ return nil
108108+ end
109109+110110+ return { hunks = hunks }
111111+end
112112+113113+--- Invert a patch (swap add/remove operations and old/new positions).
114114+---@param patch Patch The patch to invert.
115115+---@return Patch The inverted patch.
116116+function M.invert_patch(patch)
117117+ local inverted_hunks = {}
118118+119119+ for _, hunk in ipairs(patch.hunks) do
120120+ local inverted_lines = {}
121121+122122+ for _, line in ipairs(hunk.lines) do
123123+ local inverted_type = line.type
124124+ if line.type == "add" then
125125+ inverted_type = "remove"
126126+ elseif line.type == "remove" then
127127+ inverted_type = "add"
128128+ end
129129+130130+ table.insert(inverted_lines, {
131131+ type = inverted_type,
132132+ content = line.content,
133133+ })
134134+ end
135135+136136+ -- Swap old and new positions.
137137+ table.insert(inverted_hunks, {
138138+ old_start = hunk.new_start,
139139+ old_count = hunk.new_count,
140140+ new_start = hunk.old_start,
141141+ new_count = hunk.old_count,
142142+ lines = inverted_lines,
143143+ })
144144+ end
145145+146146+ return { hunks = inverted_hunks }
147147+end
148148+149149+--- Apply a patch to file contents.
150150+---@param file_lines string[] The current file contents.
151151+---@param patch Patch The patch to apply.
152152+---@return string[] The file contents after applying the patch.
153153+function M.apply_patch(file_lines, patch)
154154+ local result = {}
155155+ local i = 1
156156+157157+ for _, hunk in ipairs(patch.hunks) do
158158+ -- Copy unchanged lines before this hunk.
159159+ while i < hunk.old_start do
160160+ table.insert(result, file_lines[i])
161161+ i = i + 1
162162+ end
163163+164164+ -- Process hunk lines.
165165+ for _, line in ipairs(hunk.lines) do
166166+ if line.type == "context" then
167167+ -- Context line - should match current file.
168168+ table.insert(result, line.content)
169169+ i = i + 1
170170+ elseif line.type == "remove" then
171171+ -- Line removed - skip it in the result, advance in current file.
172172+ i = i + 1
173173+ elseif line.type == "add" then
174174+ -- Line added - add to result, don't advance in current file.
175175+ table.insert(result, line.content)
176176+ end
177177+ end
178178+ end
179179+180180+ -- Copy any remaining unchanged lines.
181181+ while i <= #file_lines do
182182+ table.insert(result, file_lines[i])
183183+ i = i + 1
184184+ end
185185+186186+ return result
187187+end
188188+189189+return M
+38-3
lua/vclib/testing.lua
···11local M = {}
2233+--- Helper to parse multiline strings into lines, stripping common indentation.
44+---@param s string
55+---@return string[]
66+function M.dedent_into_lines(s)
77+ local l = 1
88+ while s:sub(l, l) == "\n" do
99+ l = l + 1
1010+ end
1111+ local r = #s
1212+ while true do
1313+ local c = s:sub(r, r)
1414+ if c ~= "\n" and c ~= " " then
1515+ break
1616+ end
1717+ r = r - 1
1818+ end
1919+ local stripped = s:sub(l, r)
2020+ local lines = vim.split(stripped, "\n", { plain = true })
2121+ local min_indent = math.huge
2222+ for _, line in ipairs(lines) do
2323+ local indent = #line - #line:gsub("^%s*", "")
2424+ if #line > 0 and indent < min_indent then
2525+ min_indent = indent
2626+ end
2727+ end
2828+ if min_indent == math.huge then
2929+ min_indent = 0
3030+ end
3131+ for i, line in ipairs(lines) do
3232+ lines[i] = line:sub(min_indent + 1)
3333+ end
3434+ return lines
3535+end
3636+337local function _run_test_suite(suite_name, test_suite)
438 local suite_failed = 0
539 local suite_total = 0
···5993 end
6094end
61956262-function M.assert_list_eq(actual, expected)
9696+function M.assert_list_eq(actual, expected, msg_prefix)
9797+ msg_prefix = msg_prefix or ""
6398 assert(
6499 #actual == #expected,
6565- string.format("Lists have different lengths: %d vs %d", #actual, #expected)
100100+ string.format(msg_prefix .. "Lists have different lengths: %d vs %d", #actual, #expected)
66101 )
67102 local diff = ""
68103 for i = 1, #expected do
···72107 end
73108 end
74109 if diff ~= "" then
7575- error("Lists differ:" .. diff)
110110+ error(msg_prefix .. "Lists differ:" .. diff)
76111 end
77112end
78113