post inicial
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user