// 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) }