package fileedit import ( "fmt" "sort" "strings" ) // Edit is one replacement: a text to find, a text to put in its place. The // JSON names are the ones the model sees in the edit_file schema. type Edit struct { Old string `json:"old" jsonschema_description:"Exact text to find: copied from the file character for character, and present exactly once"` New string `json:"new" jsonschema_description:"Replacement text; empty deletes the matched text"` } // span is the region an edit occupies in the ORIGINAL body. type span struct { start, end int index int // rank of the edit, for error messages } // apply applies every edit to the body and returns the result. // // The rules of the `edit` CLI, kept to the letter because they are what makes // an edit verifiable: each `old` appears once and only once; edits are resolved // against the ORIGINAL body, never in cascade; two edits must not touch, nest // or repeat; an empty `new` deletes. An ambiguous edit — two occurrences, two // overlapping regions — would give a result that depends on application order, // hence a file nobody has read. Nothing is written when one edit fails: a // half-edited file is the worst outcome, because it looks like a success. func apply(body string, list []Edit) (string, error) { if len(list) == 0 { return "", fmt.Errorf("no edit given: each edit needs an old and a new text") } spans := make([]span, 0, len(list)) for i, e := range list { s, err := locate(body, e, i) if err != nil { return "", err } spans = append(spans, s) } // Sorted by position: the overlap check and the rebuild both walk the file // in its order, not in the order of the request. sort.Slice(spans, func(i, j int) bool { return spans[i].start < spans[j].start }) for i := 1; i < len(spans); i++ { if spans[i].start < spans[i-1].end { a, b := spans[i-1], spans[i] return "", fmt.Errorf( "edits #%d and #%d overlap (lines %d-%d and %d-%d): merge them into a single old/new pair", a.index+1, b.index+1, lineOf(body, a.start), lineOf(body, a.end), lineOf(body, b.start), lineOf(body, b.end)) } } var b strings.Builder at := 0 for _, s := range spans { b.WriteString(body[at:s.start]) b.WriteString(list[s.index].New) at = s.end } b.WriteString(body[at:]) return b.String(), nil } // locate finds the single occurrence of an `old` text. // // The messages are written FOR A MODEL: they say what is wrong and the move // that fixes it, because a model is what will retry the call. func locate(body string, e Edit, i int) (span, error) { if e.Old == "" { return span{}, fmt.Errorf( "edit #%d has an empty old text: it would match everywhere — use write_file to replace the whole file", i+1) } if e.Old == e.New { return span{}, fmt.Errorf("edit #%d changes nothing: old and new are identical", i+1) } switch n := strings.Count(body, e.Old); { case n == 0: return span{}, fmt.Errorf( "edit #%d: old text not found — it must match the file exactly, including indentation and line breaks:\n%s", i+1, quote(e.Old)) case n > 1: return span{}, fmt.Errorf( "edit #%d: old text appears %d times — add the surrounding lines until it is unique:\n%s", i+1, n, quote(e.Old)) } start := strings.Index(body, e.Old) return span{start: start, end: start + len(e.Old), index: i}, nil } // lineOf returns the 1-based line number of an offset in the body. func lineOf(body string, offset int) int { return 1 + strings.Count(body[:offset], "\n") } // quote shows the searched text, indented, capped to its first lines: past // that the error would drown the output and teach nothing more. func quote(s string) string { const maxLines = 6 lines := strings.Split(s, "\n") shown := lines if len(lines) > maxLines { shown = lines[:maxLines] } var b strings.Builder for _, l := range shown { b.WriteString(" │ " + l + "\n") } if len(lines) > len(shown) { fmt.Fprintf(&b, " └ … %d more line(s)\n", len(lines)-len(shown)) } return strings.TrimRight(b.String(), "\n") }