279 lines
7.4 KiB
Go
279 lines
7.4 KiB
Go
// Package patch aplica y genera diffs unificados (formato "diff -u" / "git diff").
|
|
//
|
|
// Existe para que el flujo de parches (§40) no dependa de que "patch" o "git"
|
|
// estén instalados (una instalación mínima de Debian no trae "patch") y para
|
|
// poder aplicar un parche de forma atómica: primero se calcula el resultado
|
|
// de todos los archivos en memoria y solo si todos aplican se escribe algo.
|
|
//
|
|
// El aplicador es estricto con el contenido (sin "fuzz": las líneas de
|
|
// contexto deben coincidir exactamente) pero tolera desplazamientos de
|
|
// posición, como hace patch(1) cuando el archivo cambió por encima o por
|
|
// debajo del hunk.
|
|
package patch
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// FilePatch son los cambios de un archivo.
|
|
type FilePatch struct {
|
|
OldPath string // tal como aparece tras "--- " (sin marca de tiempo)
|
|
NewPath string // tal como aparece tras "+++ "
|
|
Hunks []Hunk
|
|
IsNew bool // --- /dev/null
|
|
IsDelete bool // +++ /dev/null
|
|
}
|
|
|
|
// Hunk es un bloque "@@ -a,b +c,d @@".
|
|
type Hunk struct {
|
|
OldStart, OldLines int
|
|
NewStart, NewLines int
|
|
// Lines con prefijo ' ', '-' o '+'.
|
|
Lines []string
|
|
// OldNoEOL / NewNoEOL: "\ No newline at end of file" tras la última
|
|
// línea del lado antiguo / nuevo.
|
|
OldNoEOL, NewNoEOL bool
|
|
}
|
|
|
|
var hunkRe = regexp.MustCompile(`^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@`)
|
|
|
|
// Parse interpreta un diff unificado con uno o varios archivos.
|
|
func Parse(diff string) ([]FilePatch, error) {
|
|
lines := strings.Split(strings.ReplaceAll(diff, "\r\n", "\n"), "\n")
|
|
var out []FilePatch
|
|
var cur *FilePatch
|
|
for i := 0; i < len(lines); i++ {
|
|
line := lines[i]
|
|
switch {
|
|
case strings.HasPrefix(line, "--- ") && i+1 < len(lines) && strings.HasPrefix(lines[i+1], "+++ "):
|
|
out = append(out, FilePatch{OldPath: headerPath(line[4:]), NewPath: headerPath(lines[i+1][4:])})
|
|
cur = &out[len(out)-1]
|
|
cur.IsNew = cur.OldPath == "/dev/null"
|
|
cur.IsDelete = cur.NewPath == "/dev/null"
|
|
i++
|
|
case strings.HasPrefix(line, "@@ "):
|
|
if cur == nil {
|
|
return nil, fmt.Errorf("line %d: hunk without file header (--- / +++)", i+1)
|
|
}
|
|
m := hunkRe.FindStringSubmatch(line)
|
|
if m == nil {
|
|
return nil, fmt.Errorf("line %d: malformed hunk header %q", i+1, line)
|
|
}
|
|
h := Hunk{OldStart: atoi(m[1]), OldLines: atoiDefault(m[2], 1), NewStart: atoi(m[3]), NewLines: atoiDefault(m[4], 1)}
|
|
oldSeen, newSeen := 0, 0
|
|
j := i + 1
|
|
for ; j < len(lines) && (oldSeen < h.OldLines || newSeen < h.NewLines); j++ {
|
|
l := lines[j]
|
|
if l == "" && j == len(lines)-1 {
|
|
break // fin del texto: el hunk está incompleto
|
|
}
|
|
if l == "" {
|
|
// Algunos editores eliminan el espacio de las líneas de
|
|
// contexto vacías: se interpreta como contexto vacío.
|
|
l = " "
|
|
}
|
|
switch l[0] {
|
|
case ' ':
|
|
oldSeen++
|
|
newSeen++
|
|
case '-':
|
|
oldSeen++
|
|
case '+':
|
|
newSeen++
|
|
case '\\':
|
|
markNoEOL(&h)
|
|
continue
|
|
default:
|
|
return nil, fmt.Errorf("line %d: unexpected line in hunk: %q", j+1, l)
|
|
}
|
|
h.Lines = append(h.Lines, l)
|
|
}
|
|
if oldSeen != h.OldLines || newSeen != h.NewLines {
|
|
return nil, fmt.Errorf("line %d: hunk is truncated (expected -%d +%d lines, got -%d +%d)", i+1, h.OldLines, h.NewLines, oldSeen, newSeen)
|
|
}
|
|
if j < len(lines) && strings.HasPrefix(lines[j], `\`) {
|
|
markNoEOL(&h)
|
|
j++
|
|
}
|
|
cur.Hunks = append(cur.Hunks, h)
|
|
i = j - 1
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return nil, fmt.Errorf("no file headers found (expected unified diff with ---/+++ lines)")
|
|
}
|
|
for _, fp := range out {
|
|
if len(fp.Hunks) == 0 {
|
|
return nil, fmt.Errorf("%s: no hunks", fp.NewPath)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// markNoEOL asigna "\ No newline" al lado de la última línea del hunk.
|
|
func markNoEOL(h *Hunk) {
|
|
if len(h.Lines) == 0 {
|
|
return
|
|
}
|
|
switch h.Lines[len(h.Lines)-1][0] {
|
|
case '-':
|
|
h.OldNoEOL = true
|
|
case '+':
|
|
h.NewNoEOL = true
|
|
default:
|
|
h.OldNoEOL, h.NewNoEOL = true, true
|
|
}
|
|
}
|
|
|
|
// headerPath quita la marca de tiempo ("\t2026-...") de una cabecera.
|
|
func headerPath(s string) string {
|
|
if i := strings.IndexByte(s, '\t'); i >= 0 {
|
|
s = s[:i]
|
|
}
|
|
return strings.TrimSpace(s)
|
|
}
|
|
|
|
// Target devuelve la ruta a modificar quitando strip componentes (como -pN).
|
|
// Con strip < 0 se detecta: 1 si las rutas empiezan por a/ y b/ (git), 0 si no.
|
|
func (fp FilePatch) Target(strip int) string {
|
|
p := fp.NewPath
|
|
if fp.IsDelete {
|
|
p = fp.OldPath
|
|
}
|
|
if strip < 0 {
|
|
strip = 0
|
|
if (strings.HasPrefix(fp.OldPath, "a/") || fp.IsNew) && (strings.HasPrefix(fp.NewPath, "b/") || fp.IsDelete) {
|
|
strip = 1
|
|
}
|
|
}
|
|
parts := strings.Split(p, "/")
|
|
if strip >= len(parts) {
|
|
return parts[len(parts)-1]
|
|
}
|
|
return strings.Join(parts[strip:], "/")
|
|
}
|
|
|
|
// Apply aplica los hunks a old y devuelve el contenido nuevo.
|
|
func (fp FilePatch) Apply(old string) (string, error) {
|
|
if fp.IsNew && old != "" {
|
|
return "", fmt.Errorf("patch creates the file but it already exists")
|
|
}
|
|
src, srcEOL := splitLines(old)
|
|
var out []string
|
|
pos := 0 // siguiente línea de src sin copiar
|
|
offset := 0 // desplazamiento acumulado entre posición declarada y real
|
|
newEOL := srcEOL
|
|
|
|
for hi, h := range fp.Hunks {
|
|
var want, repl []string
|
|
for _, l := range h.Lines {
|
|
switch l[0] {
|
|
case ' ':
|
|
want = append(want, l[1:])
|
|
repl = append(repl, l[1:])
|
|
case '-':
|
|
want = append(want, l[1:])
|
|
case '+':
|
|
repl = append(repl, l[1:])
|
|
}
|
|
}
|
|
start := h.OldStart - 1 + offset
|
|
if h.OldLines == 0 {
|
|
start = h.OldStart + offset // inserción: OldStart es la línea anterior
|
|
}
|
|
at := findBlock(src, want, max(start, pos), pos)
|
|
if at < 0 {
|
|
return "", fmt.Errorf("hunk %d (@@ -%d,%d) does not apply: context not found", hi+1, h.OldStart, h.OldLines)
|
|
}
|
|
out = append(out, src[pos:at]...)
|
|
out = append(out, repl...)
|
|
pos = at + len(want)
|
|
offset = at - (h.OldStart - 1)
|
|
if h.OldLines == 0 {
|
|
offset = at - h.OldStart
|
|
}
|
|
if pos == len(src) {
|
|
// El hunk llega al final: su marca de EOL decide el final del archivo.
|
|
newEOL = !h.NewNoEOL
|
|
}
|
|
}
|
|
out = append(out, src[pos:]...)
|
|
if fp.IsDelete {
|
|
if len(out) != 0 {
|
|
return "", fmt.Errorf("patch deletes the file but content remains after applying it")
|
|
}
|
|
return "", nil
|
|
}
|
|
return joinLines(out, newEOL), nil
|
|
}
|
|
|
|
// findBlock busca want en src empezando cerca de hint y alejándose en ambas
|
|
// direcciones, sin retroceder antes de minPos. Devuelve -1 si no está.
|
|
func findBlock(src, want []string, hint, minPos int) int {
|
|
if len(want) == 0 {
|
|
return min(max(hint, minPos), len(src))
|
|
}
|
|
for d := 0; d <= len(src); d++ {
|
|
for _, p := range []int{hint + d, hint - d} {
|
|
if d == 0 && p != hint {
|
|
continue
|
|
}
|
|
if p < minPos || p+len(want) > len(src) {
|
|
continue
|
|
}
|
|
if equal(src[p:p+len(want)], want) {
|
|
return p
|
|
}
|
|
}
|
|
if hint+d > len(src) && hint-d < minPos {
|
|
break
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func equal(a, b []string) bool {
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// splitLines divide en líneas e informa si el texto terminaba en "\n".
|
|
func splitLines(s string) ([]string, bool) {
|
|
if s == "" {
|
|
return nil, true
|
|
}
|
|
eol := strings.HasSuffix(s, "\n")
|
|
s = strings.TrimSuffix(s, "\n")
|
|
return strings.Split(s, "\n"), eol
|
|
}
|
|
|
|
func joinLines(lines []string, eol bool) string {
|
|
if len(lines) == 0 {
|
|
return ""
|
|
}
|
|
s := strings.Join(lines, "\n")
|
|
if eol {
|
|
s += "\n"
|
|
}
|
|
return s
|
|
}
|
|
|
|
func atoi(s string) int {
|
|
n, _ := strconv.Atoi(s)
|
|
return n
|
|
}
|
|
|
|
func atoiDefault(s string, def int) int {
|
|
if s == "" {
|
|
return def
|
|
}
|
|
return atoi(s)
|
|
}
|