post inicial

This commit is contained in:
lastdragon
2026-09-27 16:04:02 -06:00
parent d84587e1f3
commit 52864794f5
67 changed files with 15023 additions and 2 deletions
+226
View File
@@ -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
}
+278
View File
@@ -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)
}
+202
View File
@@ -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")
}
}
}