post inicial
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
package patch
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Límites de la generación de diffs: por encima, usar "diff -u" del sistema.
|
||||
const (
|
||||
maxDiffLines = 50000
|
||||
maxEditDist = 5000
|
||||
)
|
||||
|
||||
// ErrTooDifferent indica que los archivos difieren demasiado para calcular
|
||||
// el diff con memoria acotada.
|
||||
var ErrTooDifferent = errors.New("files are too different to diff efficiently; use exec diff -u")
|
||||
|
||||
type opKind byte
|
||||
|
||||
const (
|
||||
opEqual opKind = ' '
|
||||
opDelete opKind = '-'
|
||||
opInsert opKind = '+'
|
||||
)
|
||||
|
||||
type edit struct {
|
||||
kind opKind
|
||||
text string
|
||||
a, b int // índices en old/new (0-based) para calcular cabeceras
|
||||
}
|
||||
|
||||
// Unified genera un diff unificado entre old y new con context líneas de
|
||||
// contexto. Devuelve "" si son iguales. Sirve para documentar exactamente
|
||||
// los cambios realizados (§40, §88) y para crear parches propios.
|
||||
func Unified(oldName, newName, old, new string, context int) (string, error) {
|
||||
if old == new {
|
||||
return "", nil
|
||||
}
|
||||
// Las líneas conservan su "\n": así una última línea sin salto final se
|
||||
// distingue de la misma línea con salto, y el diff lo refleja.
|
||||
a, b := splitKeep(old), splitKeep(new)
|
||||
if len(a) > maxDiffLines || len(b) > maxDiffLines {
|
||||
return "", ErrTooDifferent
|
||||
}
|
||||
edits, err := myers(a, b)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, "--- %s\n+++ %s\n", oldName, newName)
|
||||
// Agrupar cambios en hunks con contexto.
|
||||
for i := 0; i < len(edits); {
|
||||
// Buscar el siguiente cambio.
|
||||
for i < len(edits) && edits[i].kind == opEqual {
|
||||
i++
|
||||
}
|
||||
if i >= len(edits) {
|
||||
break
|
||||
}
|
||||
start := max(i-context, 0)
|
||||
end := i
|
||||
// Extender mientras los cambios estén separados por <= 2*context iguales.
|
||||
for end < len(edits) {
|
||||
if edits[end].kind != opEqual {
|
||||
end++
|
||||
continue
|
||||
}
|
||||
run := end
|
||||
for run < len(edits) && edits[run].kind == opEqual {
|
||||
run++
|
||||
}
|
||||
if run >= len(edits) || run-end > 2*context {
|
||||
end = min(end+context, len(edits))
|
||||
break
|
||||
}
|
||||
end = run
|
||||
}
|
||||
writeHunk(&sb, edits[start:end])
|
||||
i = end
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func writeHunk(sb *strings.Builder, es []edit) {
|
||||
oldStart, newStart := -1, -1
|
||||
oldN, newN := 0, 0
|
||||
for _, e := range es {
|
||||
if e.kind != opInsert {
|
||||
if oldStart < 0 {
|
||||
oldStart = e.a
|
||||
}
|
||||
oldN++
|
||||
}
|
||||
if e.kind != opDelete {
|
||||
if newStart < 0 {
|
||||
newStart = e.b
|
||||
}
|
||||
newN++
|
||||
}
|
||||
}
|
||||
// Para lados vacíos, la convención es la línea anterior (o 0).
|
||||
if oldStart < 0 {
|
||||
oldStart = es[0].a - 1
|
||||
}
|
||||
if newStart < 0 {
|
||||
newStart = es[0].b - 1
|
||||
}
|
||||
fmt.Fprintf(sb, "@@ -%s +%s @@\n", rangeStr(oldStart, oldN), rangeStr(newStart, newN))
|
||||
for _, e := range es {
|
||||
sb.WriteByte(byte(e.kind))
|
||||
sb.WriteString(strings.TrimSuffix(e.text, "\n"))
|
||||
sb.WriteByte('\n')
|
||||
if !strings.HasSuffix(e.text, "\n") {
|
||||
sb.WriteString("\\ No newline at end of file\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// splitKeep divide en líneas conservando el "\n" de cada una.
|
||||
func splitKeep(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
lines := strings.SplitAfter(s, "\n")
|
||||
if lines[len(lines)-1] == "" {
|
||||
lines = lines[:len(lines)-1]
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func rangeStr(start, n int) string {
|
||||
if n == 1 {
|
||||
return fmt.Sprint(start + 1)
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Sprintf("%d,0", start+1)
|
||||
}
|
||||
return fmt.Sprintf("%d,%d", start+1, n)
|
||||
}
|
||||
|
||||
// myers calcula el script de edición mínimo (algoritmo de Myers, O((N+M)D)).
|
||||
// Guarda solo la franja [-d, d] de cada iteración, así la memoria es O(D²).
|
||||
func myers(a, b []string) ([]edit, error) {
|
||||
n, m := len(a), len(b)
|
||||
var trace [][]int
|
||||
v := map[int]int{1: 0}
|
||||
var dFinal int
|
||||
found := false
|
||||
for d := 0; d <= n+m; d++ {
|
||||
if d > maxEditDist {
|
||||
return nil, ErrTooDifferent
|
||||
}
|
||||
snap := make([]int, 2*d+3)
|
||||
for k := -d; k <= d; k += 2 {
|
||||
var x int
|
||||
if k == -d || (k != d && v[k-1] < v[k+1]) {
|
||||
x = v[k+1]
|
||||
} else {
|
||||
x = v[k-1] + 1
|
||||
}
|
||||
y := x - k
|
||||
for x < n && y < m && a[x] == b[y] {
|
||||
x++
|
||||
y++
|
||||
}
|
||||
v[k] = x
|
||||
if x >= n && y >= m {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
for k := -d - 1; k <= d+1; k++ {
|
||||
snap[k+d+1] = v[k]
|
||||
}
|
||||
trace = append(trace, snap)
|
||||
if found {
|
||||
dFinal = d
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
get := func(d, k int) int {
|
||||
s := trace[d]
|
||||
idx := k + d + 1
|
||||
if idx < 0 || idx >= len(s) {
|
||||
return 0
|
||||
}
|
||||
return s[idx]
|
||||
}
|
||||
var rev []edit
|
||||
x, y := n, m
|
||||
for d := dFinal; d > 0; d-- {
|
||||
k := x - y
|
||||
var prevK int
|
||||
if k == -d || (k != d && get(d-1, k-1) < get(d-1, k+1)) {
|
||||
prevK = k + 1
|
||||
} else {
|
||||
prevK = k - 1
|
||||
}
|
||||
prevX := get(d-1, prevK)
|
||||
prevY := prevX - prevK
|
||||
for x > prevX && y > prevY {
|
||||
x--
|
||||
y--
|
||||
rev = append(rev, edit{kind: opEqual, text: a[x], a: x, b: y})
|
||||
}
|
||||
if x == prevX {
|
||||
y--
|
||||
rev = append(rev, edit{kind: opInsert, text: b[y], a: x, b: y})
|
||||
} else {
|
||||
x--
|
||||
rev = append(rev, edit{kind: opDelete, text: a[x], a: x, b: y})
|
||||
}
|
||||
}
|
||||
for x > 0 && y > 0 {
|
||||
x--
|
||||
y--
|
||||
rev = append(rev, edit{kind: opEqual, text: a[x], a: x, b: y})
|
||||
}
|
||||
out := make([]edit, len(rev))
|
||||
for i := range rev {
|
||||
out[i] = rev[len(rev)-1-i]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package patch
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func roundTrip(t *testing.T, name, old, new string) {
|
||||
t.Helper()
|
||||
d, err := Unified("a/f", "b/f", old, new, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: Unified: %v", name, err)
|
||||
}
|
||||
if old == new {
|
||||
if d != "" {
|
||||
t.Errorf("%s: identical inputs must give empty diff", name)
|
||||
}
|
||||
return
|
||||
}
|
||||
fps, err := Parse(d)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: Parse: %v\n%s", name, err, d)
|
||||
}
|
||||
got, err := fps[0].Apply(old)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: Apply: %v\n%s", name, err, d)
|
||||
}
|
||||
if got != new {
|
||||
t.Fatalf("%s: round trip mismatch\nwant %q\ngot %q\ndiff:\n%s", name, new, got, d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTripCases(t *testing.T) {
|
||||
cases := map[string][2]string{
|
||||
"change middle": {"a\nb\nc\nd\ne\n", "a\nb\nX\nd\ne\n"},
|
||||
"insert start": {"a\nb\n", "new\na\nb\n"},
|
||||
"insert end": {"a\nb\n", "a\nb\nnew\n"},
|
||||
"delete all": {"a\nb\n", ""},
|
||||
"from empty": {"", "a\nb\n"},
|
||||
"drop final newline": {"a\nb\n", "a\nb"},
|
||||
"add final newline": {"a\nb", "a\nb\n"},
|
||||
"no newline change": {"a\nb", "a\nc"},
|
||||
"two distant hunks": {strings.Repeat("x\n", 20) + "a\n" + strings.Repeat("y\n", 20) + "b\n", strings.Repeat("x\n", 20) + "A\n" + strings.Repeat("y\n", 20) + "B\n"},
|
||||
"sshd port": {"# comment\nPort 22\nPermitRootLogin yes\n", "# comment\nPort 2222\nPermitRootLogin no\n"},
|
||||
"identical": {"a\n", "a\n"},
|
||||
"blank context lines": {"a\n\n\nb\n", "a\n\n\nc\n"},
|
||||
}
|
||||
for name, c := range cases {
|
||||
roundTrip(t, name, c[0], c[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTripRandom(t *testing.T) {
|
||||
r := rand.New(rand.NewSource(42))
|
||||
words := []string{"alpha", "beta", "gamma", "", "delta", "listen 80;", "}"}
|
||||
gen := func(n int) []string {
|
||||
var l []string
|
||||
for i := 0; i < n; i++ {
|
||||
l = append(l, words[r.Intn(len(words))])
|
||||
}
|
||||
return l
|
||||
}
|
||||
for i := 0; i < 300; i++ {
|
||||
a := gen(r.Intn(40))
|
||||
b := append([]string(nil), a...)
|
||||
for j := r.Intn(6); j > 0; j-- {
|
||||
switch op := r.Intn(3); {
|
||||
case op == 0 && len(b) > 0:
|
||||
k := r.Intn(len(b))
|
||||
b = append(b[:k], b[k+1:]...)
|
||||
case op == 1:
|
||||
k := r.Intn(len(b) + 1)
|
||||
b = append(b[:k], append([]string{words[r.Intn(len(words))]}, b[k:]...)...)
|
||||
case len(b) > 0:
|
||||
b[r.Intn(len(b))] = "changed"
|
||||
}
|
||||
}
|
||||
old, new := strings.Join(a, "\n"), strings.Join(b, "\n")
|
||||
if r.Intn(2) == 0 && old != "" {
|
||||
old += "\n"
|
||||
}
|
||||
if r.Intn(2) == 0 && new != "" {
|
||||
new += "\n"
|
||||
}
|
||||
roundTrip(t, "random", old, new)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyWithOffset(t *testing.T) {
|
||||
d, _ := Unified("a/f", "b/f", "a\nb\nc\n", "a\nB\nc\n", 1)
|
||||
fps, _ := Parse(d)
|
||||
// El archivo real tiene líneas extra al principio: el hunk se desplaza.
|
||||
got, err := fps[0].Apply("x\ny\nz\na\nb\nc\n")
|
||||
if err != nil || got != "x\ny\nz\na\nB\nc\n" {
|
||||
t.Fatalf("got %q err %v", got, err)
|
||||
}
|
||||
if _, err := fps[0].Apply("a\nDIFFERENT\nc\n"); err == nil {
|
||||
t.Error("context mismatch must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGitDiffAndTargets(t *testing.T) {
|
||||
d := `diff --git a/src/main.c b/src/main.c
|
||||
index 83db48f..bf269f4 100644
|
||||
--- a/src/main.c 2026-09-27 10:00:00
|
||||
+++ b/src/main.c 2026-09-27 10:05:00
|
||||
@@ -1,3 +1,3 @@
|
||||
int main() {
|
||||
- return 1;
|
||||
+ return 0;
|
||||
}
|
||||
diff --git a/NEWS b/NEWS
|
||||
new file mode 100644
|
||||
--- /dev/null
|
||||
+++ b/NEWS
|
||||
@@ -0,0 +1 @@
|
||||
+fixed
|
||||
--- a/old.txt
|
||||
+++ /dev/null
|
||||
@@ -1 +0,0 @@
|
||||
-bye
|
||||
`
|
||||
fps, err := Parse(d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fps) != 3 {
|
||||
t.Fatalf("files = %d", len(fps))
|
||||
}
|
||||
if fps[0].Target(-1) != "src/main.c" || fps[0].Target(0) != "b/src/main.c" || fps[0].Target(2) != "main.c" {
|
||||
t.Errorf("targets: %q %q %q", fps[0].Target(-1), fps[0].Target(0), fps[0].Target(2))
|
||||
}
|
||||
if !fps[1].IsNew || fps[1].Target(-1) != "NEWS" {
|
||||
t.Errorf("new file: %+v", fps[1])
|
||||
}
|
||||
if !fps[2].IsDelete || fps[2].Target(-1) != "old.txt" {
|
||||
t.Errorf("delete: %+v %q", fps[2], fps[2].Target(-1))
|
||||
}
|
||||
got, err := fps[0].Apply("int main() {\n return 1;\n}\n")
|
||||
if err != nil || !strings.Contains(got, "return 0;") {
|
||||
t.Errorf("apply: %q %v", got, err)
|
||||
}
|
||||
if got, err := fps[1].Apply(""); err != nil || got != "fixed\n" {
|
||||
t.Errorf("new file: %q %v", got, err)
|
||||
}
|
||||
if got, err := fps[2].Apply("bye\n"); err != nil || got != "" {
|
||||
t.Errorf("delete: %q %v", got, err)
|
||||
}
|
||||
if _, err := fps[1].Apply("exists\n"); err == nil {
|
||||
t.Error("creating an existing file must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseErrors(t *testing.T) {
|
||||
for _, d := range []string{
|
||||
"no diff here",
|
||||
"--- a/f\n+++ b/f\n@@ -1,2 +1,2 @@\n a\n", // truncado
|
||||
"--- a/f\n+++ b/f\n@@ bad @@\n",
|
||||
"--- a/f\n+++ b/f\n", // sin hunks
|
||||
"--- a/f\n+++ b/f\n@@ -1 +1 @@\n?weird\n",
|
||||
} {
|
||||
if _, err := Parse(d); err == nil {
|
||||
t.Errorf("expected error for %q", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compatibilidad con diff(1) y patch(1) del sistema, si están instalados.
|
||||
func TestSystemToolsCompatibility(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
old := "server {\n listen 80;\n server_name a;\n}\n"
|
||||
new := "server {\n listen 443 ssl;\n server_name a;\n ssl_certificate x;\n}\n"
|
||||
oldPath, newPath := filepath.Join(dir, "old"), filepath.Join(dir, "new")
|
||||
os.WriteFile(oldPath, []byte(old), 0o644)
|
||||
os.WriteFile(newPath, []byte(new), 0o644)
|
||||
|
||||
if _, err := exec.LookPath("diff"); err == nil {
|
||||
out, _ := exec.Command("diff", "-u", oldPath, newPath).Output()
|
||||
fps, err := Parse(string(out))
|
||||
if err != nil {
|
||||
t.Fatalf("parse diff -u output: %v", err)
|
||||
}
|
||||
if got, err := fps[0].Apply(old); err != nil || got != new {
|
||||
t.Errorf("apply diff -u output: %q %v", got, err)
|
||||
}
|
||||
}
|
||||
if _, err := exec.LookPath("patch"); err == nil {
|
||||
d, _ := Unified("old", "old", old, new, 3)
|
||||
cmd := exec.Command("patch", "-s", oldPath)
|
||||
cmd.Stdin = strings.NewReader(d)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("patch(1) rejected our diff: %v %s", err, out)
|
||||
}
|
||||
if got, _ := os.ReadFile(oldPath); string(got) != new {
|
||||
t.Errorf("patch(1) result mismatch")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user