summaryrefslogtreecommitdiff
path: root/src/luarocks/tools/patch.lua
diff options
context:
space:
mode:
authorMike Vink <mike@pionative.com>2025-02-03 21:29:42 +0100
committerMike Vink <mike@pionative.com>2025-02-03 21:29:42 +0100
commit5155816b7b925dec5d5feb1568b1d7ceb00938b9 (patch)
treedeca28ea15e79f6f804c3d90d2ba757881638af5 /src/luarocks/tools/patch.lua
fetch tarballHEADmaster
Diffstat (limited to 'src/luarocks/tools/patch.lua')
-rw-r--r--src/luarocks/tools/patch.lua716
1 files changed, 716 insertions, 0 deletions
diff --git a/src/luarocks/tools/patch.lua b/src/luarocks/tools/patch.lua
new file mode 100644
index 0000000..6f36d71
--- /dev/null
+++ b/src/luarocks/tools/patch.lua
@@ -0,0 +1,716 @@
+--- Patch utility to apply unified diffs.
+--
+-- http://lua-users.org/wiki/LuaPatch
+--
+-- (c) 2008 David Manura, Licensed under the same terms as Lua (MIT license).
+-- Code is heavily based on the Python-based patch.py version 8.06-1
+-- Copyright (c) 2008 rainforce.org, MIT License
+-- Project home: http://code.google.com/p/python-patch/ .
+-- Version 0.1
+
+local patch = {}
+
+local fs = require("luarocks.fs")
+local fun = require("luarocks.fun")
+
+local io = io
+local os = os
+local string = string
+local table = table
+local format = string.format
+
+-- logging
+local debugmode = false
+local function debug(_) end
+local function info(_) end
+local function warning(s) io.stderr:write(s .. '\n') end
+
+-- Returns boolean whether string s2 starts with string s.
+local function startswith(s, s2)
+ return s:sub(1, #s2) == s2
+end
+
+-- Returns boolean whether string s2 ends with string s.
+local function endswith(s, s2)
+ return #s >= #s2 and s:sub(#s-#s2+1) == s2
+end
+
+-- Returns string s after filtering out any new-line characters from end.
+local function endlstrip(s)
+ return s:gsub('[\r\n]+$', '')
+end
+
+-- Returns shallow copy of table t.
+local function table_copy(t)
+ local t2 = {}
+ for k,v in pairs(t) do t2[k] = v end
+ return t2
+end
+
+local function exists(filename)
+ local fh = io.open(filename)
+ local result = fh ~= nil
+ if fh then fh:close() end
+ return result
+end
+local function isfile() return true end --FIX?
+
+local function string_as_file(s)
+ return {
+ at = 0,
+ str = s,
+ len = #s,
+ eof = false,
+ read = function(self, n)
+ if self.eof then return nil end
+ local chunk = self.str:sub(self.at, self.at + n - 1)
+ self.at = self.at + n
+ if self.at > self.len then
+ self.eof = true
+ end
+ return chunk
+ end,
+ close = function(self)
+ self.eof = true
+ end,
+ }
+end
+
+--
+-- file_lines(f) is similar to f:lines() for file f.
+-- The main difference is that read_lines includes
+-- new-line character sequences ("\n", "\r\n", "\r"),
+-- if any, at the end of each line. Embedded "\0" are also handled.
+-- Caution: The newline behavior can depend on whether f is opened
+-- in binary or ASCII mode.
+-- (file_lines - version 20080913)
+--
+local function file_lines(f)
+ local CHUNK_SIZE = 1024
+ local buffer = ""
+ local pos_beg = 1
+ return function()
+ local pos, chars
+ while 1 do
+ pos, chars = buffer:match('()([\r\n].)', pos_beg)
+ if pos or not f then
+ break
+ elseif f then
+ local chunk = f:read(CHUNK_SIZE)
+ if chunk then
+ buffer = buffer:sub(pos_beg) .. chunk
+ pos_beg = 1
+ else
+ f = nil
+ end
+ end
+ end
+ if not pos then
+ pos = #buffer
+ elseif chars == '\r\n' then
+ pos = pos + 1
+ end
+ local line = buffer:sub(pos_beg, pos)
+ pos_beg = pos + 1
+ if #line > 0 then
+ return line
+ end
+ end
+end
+
+local function match_linerange(line)
+ local m1, m2, m3, m4 = line:match("^@@ %-(%d+),(%d+) %+(%d+),(%d+)")
+ if not m1 then m1, m3, m4 = line:match("^@@ %-(%d+) %+(%d+),(%d+)") end
+ if not m1 then m1, m2, m3 = line:match("^@@ %-(%d+),(%d+) %+(%d+)") end
+ if not m1 then m1, m3 = line:match("^@@ %-(%d+) %+(%d+)") end
+ return m1, m2, m3, m4
+end
+
+local function match_epoch(str)
+ return str:match("[^0-9]1969[^0-9]") or str:match("[^0-9]1970[^0-9]")
+end
+
+function patch.read_patch(filename, data)
+ -- define possible file regions that will direct the parser flow
+ local state = 'header'
+ -- 'header' - comments before the patch body
+ -- 'filenames' - lines starting with --- and +++
+ -- 'hunkhead' - @@ -R +R @@ sequence
+ -- 'hunkbody'
+ -- 'hunkskip' - skipping invalid hunk mode
+
+ local all_ok = true
+ local lineends = {lf=0, crlf=0, cr=0}
+ local files = {source={}, target={}, epoch={}, hunks={}, fileends={}, hunkends={}}
+ local nextfileno = 0
+ local nexthunkno = 0 --: even if index starts with 0 user messages
+ -- number hunks from 1
+
+ -- hunkinfo holds parsed values, hunkactual - calculated
+ local hunkinfo = {
+ startsrc=nil, linessrc=nil, starttgt=nil, linestgt=nil,
+ invalid=false, text={}
+ }
+ local hunkactual = {linessrc=nil, linestgt=nil}
+
+ info(format("reading patch %s", filename))
+
+ local fp
+ if data then
+ fp = string_as_file(data)
+ else
+ fp = filename == '-' and io.stdin or assert(io.open(filename, "rb"))
+ end
+ local lineno = 0
+
+ for line in file_lines(fp) do
+ lineno = lineno + 1
+ if state == 'header' then
+ if startswith(line, "--- ") then
+ state = 'filenames'
+ end
+ -- state is 'header' or 'filenames'
+ end
+ if state == 'hunkbody' then
+ -- skip hunkskip and hunkbody code until definition of hunkhead read
+
+ if line:match"^[\r\n]*$" then
+ -- prepend space to empty lines to interpret them as context properly
+ line = " " .. line
+ end
+
+ -- process line first
+ if line:match"^[- +\\]" then
+ -- gather stats about line endings
+ local he = files.hunkends[nextfileno]
+ if endswith(line, "\r\n") then
+ he.crlf = he.crlf + 1
+ elseif endswith(line, "\n") then
+ he.lf = he.lf + 1
+ elseif endswith(line, "\r") then
+ he.cr = he.cr + 1
+ end
+ if startswith(line, "-") then
+ hunkactual.linessrc = hunkactual.linessrc + 1
+ elseif startswith(line, "+") then
+ hunkactual.linestgt = hunkactual.linestgt + 1
+ elseif startswith(line, "\\") then
+ -- nothing
+ else
+ hunkactual.linessrc = hunkactual.linessrc + 1
+ hunkactual.linestgt = hunkactual.linestgt + 1
+ end
+ table.insert(hunkinfo.text, line)
+ -- todo: handle \ No newline cases
+ else
+ warning(format("invalid hunk no.%d at %d for target file %s",
+ nexthunkno, lineno, files.target[nextfileno]))
+ -- add hunk status node
+ table.insert(files.hunks[nextfileno], table_copy(hunkinfo))
+ files.hunks[nextfileno][nexthunkno].invalid = true
+ all_ok = false
+ state = 'hunkskip'
+ end
+
+ -- check exit conditions
+ if hunkactual.linessrc > hunkinfo.linessrc or
+ hunkactual.linestgt > hunkinfo.linestgt
+ then
+ warning(format("extra hunk no.%d lines at %d for target %s",
+ nexthunkno, lineno, files.target[nextfileno]))
+ -- add hunk status node
+ table.insert(files.hunks[nextfileno], table_copy(hunkinfo))
+ files.hunks[nextfileno][nexthunkno].invalid = true
+ state = 'hunkskip'
+ elseif hunkinfo.linessrc == hunkactual.linessrc and
+ hunkinfo.linestgt == hunkactual.linestgt
+ then
+ table.insert(files.hunks[nextfileno], table_copy(hunkinfo))
+ state = 'hunkskip'
+
+ -- detect mixed window/unix line ends
+ local ends = files.hunkends[nextfileno]
+ if (ends.cr~=0 and 1 or 0) + (ends.crlf~=0 and 1 or 0) +
+ (ends.lf~=0 and 1 or 0) > 1
+ then
+ warning(format("inconsistent line ends in patch hunks for %s",
+ files.source[nextfileno]))
+ end
+ end
+ -- state is 'hunkbody' or 'hunkskip'
+ end
+
+ if state == 'hunkskip' then
+ if match_linerange(line) then
+ state = 'hunkhead'
+ elseif startswith(line, "--- ") then
+ state = 'filenames'
+ if debugmode and #files.source > 0 then
+ debug(format("- %2d hunks for %s", #files.hunks[nextfileno],
+ files.source[nextfileno]))
+ end
+ end
+ -- state is 'hunkskip', 'hunkhead', or 'filenames'
+ end
+ local advance
+ if state == 'filenames' then
+ if startswith(line, "--- ") then
+ if fun.contains(files.source, nextfileno) then
+ all_ok = false
+ warning(format("skipping invalid patch for %s",
+ files.source[nextfileno+1]))
+ table.remove(files.source, nextfileno+1)
+ -- double source filename line is encountered
+ -- attempt to restart from this second line
+ end
+ -- Accept a space as a terminator, like GNU patch does.
+ -- Breaks patches containing filenames with spaces...
+ -- FIXME Figure out what does GNU patch do in those cases.
+ local match, rest = line:match("^%-%-%- ([^ \t\r\n]+)(.*)")
+ if not match then
+ all_ok = false
+ warning(format("skipping invalid filename at line %d", lineno+1))
+ state = 'header'
+ else
+ if match_epoch(rest) then
+ files.epoch[nextfileno + 1] = true
+ end
+ table.insert(files.source, match)
+ end
+ elseif not startswith(line, "+++ ") then
+ if fun.contains(files.source, nextfileno) then
+ all_ok = false
+ warning(format("skipping invalid patch with no target for %s",
+ files.source[nextfileno+1]))
+ table.remove(files.source, nextfileno+1)
+ else
+ -- this should be unreachable
+ warning("skipping invalid target patch")
+ end
+ state = 'header'
+ else
+ if fun.contains(files.target, nextfileno) then
+ all_ok = false
+ warning(format("skipping invalid patch - double target at line %d",
+ lineno+1))
+ table.remove(files.source, nextfileno+1)
+ table.remove(files.target, nextfileno+1)
+ nextfileno = nextfileno - 1
+ -- double target filename line is encountered
+ -- switch back to header state
+ state = 'header'
+ else
+ -- Accept a space as a terminator, like GNU patch does.
+ -- Breaks patches containing filenames with spaces...
+ -- FIXME Figure out what does GNU patch do in those cases.
+ local re_filename = "^%+%+%+ ([^ \t\r\n]+)(.*)$"
+ local match, rest = line:match(re_filename)
+ if not match then
+ all_ok = false
+ warning(format(
+ "skipping invalid patch - no target filename at line %d",
+ lineno+1))
+ state = 'header'
+ else
+ table.insert(files.target, match)
+ nextfileno = nextfileno + 1
+ if match_epoch(rest) then
+ files.epoch[nextfileno] = true
+ end
+ nexthunkno = 0
+ table.insert(files.hunks, {})
+ table.insert(files.hunkends, table_copy(lineends))
+ table.insert(files.fileends, table_copy(lineends))
+ state = 'hunkhead'
+ advance = true
+ end
+ end
+ end
+ -- state is 'filenames', 'header', or ('hunkhead' with advance)
+ end
+ if not advance and state == 'hunkhead' then
+ local m1, m2, m3, m4 = match_linerange(line)
+ if not m1 then
+ if not fun.contains(files.hunks, nextfileno-1) then
+ all_ok = false
+ warning(format("skipping invalid patch with no hunks for file %s",
+ files.target[nextfileno]))
+ end
+ state = 'header'
+ else
+ hunkinfo.startsrc = tonumber(m1)
+ hunkinfo.linessrc = tonumber(m2 or 1)
+ hunkinfo.starttgt = tonumber(m3)
+ hunkinfo.linestgt = tonumber(m4 or 1)
+ hunkinfo.invalid = false
+ hunkinfo.text = {}
+
+ hunkactual.linessrc = 0
+ hunkactual.linestgt = 0
+
+ state = 'hunkbody'
+ nexthunkno = nexthunkno + 1
+ end
+ -- state is 'header' or 'hunkbody'
+ end
+ end
+ if state ~= 'hunkskip' then
+ warning(format("patch file incomplete - %s", filename))
+ all_ok = false
+ -- os.exit(?)
+ else
+ -- duplicated message when an eof is reached
+ if debugmode and #files.source > 0 then
+ debug(format("- %2d hunks for %s", #files.hunks[nextfileno],
+ files.source[nextfileno]))
+ end
+ end
+
+ local sum = 0; for _,hset in ipairs(files.hunks) do sum = sum + #hset end
+ info(format("total files: %d total hunks: %d", #files.source, sum))
+ fp:close()
+ return files, all_ok
+end
+
+local function find_hunk(file, h, hno)
+ for fuzz=0,2 do
+ local lineno = h.startsrc
+ for i=0,#file do
+ local found = true
+ local location = lineno
+ for l, hline in ipairs(h.text) do
+ if l > fuzz then
+ -- todo: \ No newline at the end of file
+ if startswith(hline, " ") or startswith(hline, "-") then
+ local line = file[lineno]
+ lineno = lineno + 1
+ if not line or #line == 0 then
+ found = false
+ break
+ end
+ if endlstrip(line) ~= endlstrip(hline:sub(2)) then
+ found = false
+ break
+ end
+ end
+ end
+ end
+ if found then
+ local offset = location - h.startsrc - fuzz
+ if offset ~= 0 then
+ warning(format("Hunk %d found at offset %d%s...", hno, offset, fuzz == 0 and "" or format(" (fuzz %d)", fuzz)))
+ end
+ h.startsrc = location
+ h.starttgt = h.starttgt + offset
+ for _=1,fuzz do
+ table.remove(h.text, 1)
+ table.remove(h.text, #h.text)
+ end
+ return true
+ end
+ lineno = i
+ end
+ end
+ return false
+end
+
+local function load_file(filename)
+ local fp = assert(io.open(filename))
+ local file = {}
+ local readline = file_lines(fp)
+ while true do
+ local line = readline()
+ if not line then break end
+ table.insert(file, line)
+ end
+ fp:close()
+ return file
+end
+
+local function find_hunks(file, hunks)
+ for hno, h in ipairs(hunks) do
+ find_hunk(file, h, hno)
+ end
+end
+
+local function check_patched(file, hunks)
+ local lineno = 1
+ local ok, err = pcall(function()
+ if #file == 0 then
+ error('nomatch', 0)
+ end
+ for hno, h in ipairs(hunks) do
+ -- skip to line just before hunk starts
+ if #file < h.starttgt then
+ error('nomatch', 0)
+ end
+ lineno = h.starttgt
+ for _, hline in ipairs(h.text) do
+ -- todo: \ No newline at the end of file
+ if not startswith(hline, "-") and not startswith(hline, "\\") then
+ local line = file[lineno]
+ lineno = lineno + 1
+ if #line == 0 then
+ error('nomatch', 0)
+ end
+ if endlstrip(line) ~= endlstrip(hline:sub(2)) then
+ warning(format("file is not patched - failed hunk: %d", hno))
+ error('nomatch', 0)
+ end
+ end
+ end
+ end
+ end)
+ -- todo: display failed hunk, i.e. expected/found
+ return err ~= 'nomatch'
+end
+
+local function patch_hunks(srcname, tgtname, hunks)
+ local src = assert(io.open(srcname, "rb"))
+ local tgt = assert(io.open(tgtname, "wb"))
+
+ local src_readline = file_lines(src)
+
+ -- todo: detect linefeeds early - in apply_files routine
+ -- to handle cases when patch starts right from the first
+ -- line and no lines are processed. At the moment substituted
+ -- lineends may not be the same at the start and at the end
+ -- of patching. Also issue a warning about mixed lineends
+
+ local srclineno = 1
+ local lineends = {['\n']=0, ['\r\n']=0, ['\r']=0}
+ for hno, h in ipairs(hunks) do
+ debug(format("processing hunk %d for file %s", hno, tgtname))
+ -- skip to line just before hunk starts
+ while srclineno < h.startsrc do
+ local line = src_readline()
+ -- Python 'U' mode works only with text files
+ if endswith(line, "\r\n") then
+ lineends["\r\n"] = lineends["\r\n"] + 1
+ elseif endswith(line, "\n") then
+ lineends["\n"] = lineends["\n"] + 1
+ elseif endswith(line, "\r") then
+ lineends["\r"] = lineends["\r"] + 1
+ end
+ tgt:write(line)
+ srclineno = srclineno + 1
+ end
+
+ for _,hline in ipairs(h.text) do
+ -- todo: check \ No newline at the end of file
+ if startswith(hline, "-") or startswith(hline, "\\") then
+ src_readline()
+ srclineno = srclineno + 1
+ else
+ if not startswith(hline, "+") then
+ src_readline()
+ srclineno = srclineno + 1
+ end
+ local line2write = hline:sub(2)
+ -- detect if line ends are consistent in source file
+ local sum = 0
+ for _,v in pairs(lineends) do if v > 0 then sum=sum+1 end end
+ if sum == 1 then
+ local newline
+ for k,v in pairs(lineends) do if v ~= 0 then newline = k end end
+ tgt:write(endlstrip(line2write) .. newline)
+ else -- newlines are mixed or unknown
+ tgt:write(line2write)
+ end
+ end
+ end
+ end
+ for line in src_readline do
+ tgt:write(line)
+ end
+ tgt:close()
+ src:close()
+ return true
+end
+
+local function strip_dirs(filename, strip)
+ if strip == nil then return filename end
+ for _=1,strip do
+ filename=filename:gsub("^[^/]*/", "")
+ end
+ return filename
+end
+
+local function write_new_file(filename, hunk)
+ local fh = io.open(filename, "wb")
+ if not fh then return false end
+ for _, hline in ipairs(hunk.text) do
+ local c = hline:sub(1,1)
+ if c ~= "+" and c ~= "-" and c ~= " " then
+ return false, "malformed patch"
+ end
+ fh:write(hline:sub(2))
+ end
+ fh:close()
+ return true
+end
+
+local function patch_file(source, target, epoch, hunks, strip, create_delete)
+ local create_file = false
+ if create_delete then
+ local is_src_epoch = epoch and #hunks == 1 and hunks[1].startsrc == 0 and hunks[1].linessrc == 0
+ if is_src_epoch or source == "/dev/null" then
+ info(format("will create %s", target))
+ create_file = true
+ end
+ end
+ if create_file then
+ return write_new_file(fs.absolute_name(strip_dirs(target, strip)), hunks[1])
+ end
+ source = strip_dirs(source, strip)
+ local f2patch = source
+ if not exists(f2patch) then
+ f2patch = strip_dirs(target, strip)
+ f2patch = fs.absolute_name(f2patch)
+ if not exists(f2patch) then --FIX:if f2patch nil
+ warning(format("source/target file does not exist\n--- %s\n+++ %s",
+ source, f2patch))
+ return false
+ end
+ end
+ if not isfile(f2patch) then
+ warning(format("not a file - %s", f2patch))
+ return false
+ end
+
+ source = f2patch
+
+ -- validate before patching
+ local file = load_file(source)
+ local hunkno = 1
+ local hunk = hunks[hunkno]
+ local hunkfind = {}
+ local validhunks = 0
+ local canpatch = false
+ local hunklineno
+ if not file then
+ return nil, "failed reading file " .. source
+ end
+
+ if create_delete then
+ if epoch and #hunks == 1 and hunks[1].starttgt == 0 and hunks[1].linestgt == 0 then
+ local ok = os.remove(source)
+ if not ok then
+ return false
+ end
+ info(format("successfully removed %s", source))
+ return true
+ end
+ end
+
+ find_hunks(file, hunks)
+
+ local function process_line(line, lineno)
+ if not hunk or lineno < hunk.startsrc then
+ return false
+ end
+ if lineno == hunk.startsrc then
+ hunkfind = {}
+ for _,x in ipairs(hunk.text) do
+ if x:sub(1,1) == ' ' or x:sub(1,1) == '-' then
+ hunkfind[#hunkfind+1] = endlstrip(x:sub(2))
+ end
+ end
+ hunklineno = 1
+
+ -- todo \ No newline at end of file
+ end
+ -- check hunks in source file
+ if lineno < hunk.startsrc + #hunkfind - 1 then
+ if endlstrip(line) == hunkfind[hunklineno] then
+ hunklineno = hunklineno + 1
+ else
+ debug(format("hunk no.%d doesn't match source file %s",
+ hunkno, source))
+ -- file may be already patched, but check other hunks anyway
+ hunkno = hunkno + 1
+ if hunkno <= #hunks then
+ hunk = hunks[hunkno]
+ return false
+ else
+ return true
+ end
+ end
+ end
+ -- check if processed line is the last line
+ if lineno == hunk.startsrc + #hunkfind - 1 then
+ debug(format("file %s hunk no.%d -- is ready to be patched",
+ source, hunkno))
+ hunkno = hunkno + 1
+ validhunks = validhunks + 1
+ if hunkno <= #hunks then
+ hunk = hunks[hunkno]
+ else
+ if validhunks == #hunks then
+ -- patch file
+ canpatch = true
+ return true
+ end
+ end
+ end
+ return false
+ end
+
+ local done = false
+ for lineno, line in ipairs(file) do
+ done = process_line(line, lineno)
+ if done then
+ break
+ end
+ end
+ if not done then
+ if hunkno <= #hunks and not create_file then
+ warning(format("premature end of source file %s at hunk %d",
+ source, hunkno))
+ return false
+ end
+ end
+ if validhunks < #hunks then
+ if check_patched(file, hunks) then
+ warning(format("already patched %s", source))
+ elseif not create_file then
+ warning(format("source file is different - %s", source))
+ return false
+ end
+ end
+ if not canpatch then
+ return true
+ end
+ local backupname = source .. ".orig"
+ if exists(backupname) then
+ warning(format("can't backup original file to %s - aborting",
+ backupname))
+ return false
+ end
+ local ok = os.rename(source, backupname)
+ if not ok then
+ warning(format("failed backing up %s when patching", source))
+ return false
+ end
+ patch_hunks(backupname, source, hunks)
+ info(format("successfully patched %s", source))
+ os.remove(backupname)
+ return true
+end
+
+function patch.apply_patch(the_patch, strip, create_delete)
+ local all_ok = true
+ local total = #the_patch.source
+ for fileno, source in ipairs(the_patch.source) do
+ local target = the_patch.target[fileno]
+ local hunks = the_patch.hunks[fileno]
+ local epoch = the_patch.epoch[fileno]
+ info(format("processing %d/%d:\t %s", fileno, total, source))
+ local ok = patch_file(source, target, epoch, hunks, strip, create_delete)
+ all_ok = all_ok and ok
+ end
+ -- todo: check for premature eof
+ return all_ok
+end
+
+return patch