Documentation
¶
Overview ¶
Package textedit supports file-editing tools exposed to LLM agents.
An agent's edit_file call names the text to replace by exact bytes. One way such a call misses is a constant indentation shift: every line of old_string matches the file once leading whitespace is ignored, but the file carries one more (or one fewer) tab on each line. MatchIgnoringIndentation finds the unique region that matches under that relaxation and reports the shift so the caller can apply the same shift to new_string with ShiftIndentation.
A related source of misses is a read window that starts mid-line, which hands the agent a first line with its indentation cut off. LineStart and LineEnd align a byte window to whole lines.
An agent also misses when it copies text in the escaped form a JSON tool result shows it: a backslash and "t" where the file has a tab, or \u0026 where it has "&". UnescapeJSON decodes such text when it is a whole JSON string body, and UnescapeJSONSequences decodes the escape sequences of text that mixes escaped and raw characters.
Index ¶
- Constants
- func EscapedNotFound(offsets []int64) error
- func LineEnd(r io.ReaderAt, end, size int64, bound int) (int64, error)
- func LineStart(r io.ReaderAt, offset int64, bound int) (int64, error)
- func ShiftIndentation(s string, shift Shift) string
- func UnescapeJSON(s string) (string, bool)
- func UnescapeJSONSequences(s string) string
- type Match
- type Shift
Examples ¶
Constants ¶
const UnescapedNote = `` /* 247-byte string literal not displayed */
UnescapedNote is the note for an edit that applied only after old_string and new_string were decoded with UnescapeJSON.
Variables ¶
This section is empty.
Functions ¶
func EscapedNotFound ¶ added in v0.11.40
EscapedNotFound is the error for an old_string that matches nothing as written but matches once UnescapeJSONSequences decodes it. offsets holds the byte offsets of its first one or two decoded matches; two mean the decoded text also needs more context to be unique.
func LineEnd ¶
LineEnd returns the offset one past the first "\n" at or after end, so a window ending there ends with a whole line, or size when end is at or past size. It reads at most bound bytes forward; if no newline lies within that span, including when the span reaches the end of the input, it returns end unchanged so inputs without newlines read exactly as they would without alignment.
Example ¶
package main
import (
"fmt"
"strings"
"chainguard.dev/driftlessaf/internal/textedit"
)
func main() {
data := "first line\nsecond line\nthird line\n"
r := strings.NewReader(data)
// A window that would end mid-way through "second line" is extended to
// include the rest of the line and its newline.
end, err := textedit.LineEnd(r, 15, int64(len(data)), 64<<10)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(end)
}
Output: 23
func LineStart ¶
LineStart returns the offset of the first byte of the line containing offset: one past the previous "\n", or 0 when offset is 0. It reads at most bound bytes backward; if no newline lies within that span, including when the span reaches the start of the input, it returns offset unchanged so inputs without newlines read exactly as they would without alignment.
Example ¶
package main
import (
"fmt"
"strings"
"chainguard.dev/driftlessaf/internal/textedit"
)
func main() {
r := strings.NewReader("first line\nsecond line\nthird line\n")
// A window that would begin mid-way through "second line" is moved back
// to the start of that line.
start, err := textedit.LineStart(r, 15, 64<<10)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(start)
}
Output: 11
func ShiftIndentation ¶
ShiftIndentation applies shift to every non-blank line of s: with Add it prepends Whitespace; otherwise it removes Whitespace from the start of a line that has it and leaves other lines unchanged. Blank lines are unchanged.
Example ¶
package main
import (
"fmt"
"chainguard.dev/driftlessaf/internal/textedit"
)
func main() {
replacement := "if ok {\n\treturn 2\n}\n"
shifted := textedit.ShiftIndentation(replacement, textedit.Shift{Add: true, Whitespace: "\t"})
fmt.Printf("%q\n", shifted)
restored := textedit.ShiftIndentation(shifted, textedit.Shift{Whitespace: "\t"})
fmt.Println(restored == replacement)
}
Output: "\tif ok {\n\t\treturn 2\n\t}\n" true
func UnescapeJSON ¶ added in v0.11.40
UnescapeJSON decodes s as the body of a JSON string literal. ok is false when s between double quotes is not valid JSON: s holds a raw control character such as a newline or tab, an unescaped double quote, or a backslash that starts no JSON escape sequence.
func UnescapeJSONSequences ¶ added in v0.11.40
UnescapeJSONSequences decodes every JSON escape sequence in s and keeps the rest of s as it is, so unlike UnescapeJSON it also decodes text that mixes escaped and raw characters, such as a raw newline beside an escaped quote.
Types ¶
type Match ¶
type Match struct {
Start int64 // byte offset of the region's first byte
End int64 // byte offset one past the region's last byte
Line int // 1-based line number of Start
Shift Shift
}
Match is a region of a file that equals old_string once indentation is ignored.
func MatchIgnoringIndentation ¶
MatchIgnoringIndentation scans r line by line for the unique region whose lines equal old's lines after leading spaces and tabs are stripped (and a trailing "\r" is ignored on file lines). It succeeds only when exactly one region matches and the indentation difference is the same Shift on every non-blank line. It never reads the whole input into memory: it keeps a window of len(lines(old)) lines. If old ends with "\n", the region includes the trailing newline of its last line when the file has one.
On failure the error names the reason so a caller can relay it to the agent:
- no region matches: reports whether the first non-blank line of old occurs in the file, and if so at which line and byte offset, so the agent can re-read there; otherwise says the file may have changed since it was read.
- more than one region matches: reports the count and the byte offsets of the first two, and asks for more surrounding context.
- one region matches but the shift differs across lines: reports its line and byte offset and asks the agent to copy the text exactly as read.
Every error message begins with "old_string not found in file".
Example ¶
package main
import (
"fmt"
"strings"
"chainguard.dev/driftlessaf/internal/textedit"
)
func main() {
file := "func f() {\n\tif ok {\n\t\treturn 1\n\t}\n}\n"
// The agent copied the block with one tab too few on every line.
old := "if ok {\n\treturn 1\n}"
m, err := textedit.MatchIgnoringIndentation(strings.NewReader(file), old)
if err != nil {
fmt.Println(err)
return
}
fmt.Printf("line %d, bytes [%d, %d), %s\n", m.Line, m.Start, m.End, m.Shift)
fmt.Printf("%q\n", file[m.Start:m.End])
}
Output: line 2, bytes [11, 33), added 1 tab "\tif ok {\n\t\treturn 1\n\t}"
Example (Ambiguous) ¶
package main
import (
"fmt"
"strings"
"chainguard.dev/driftlessaf/internal/textedit"
)
func main() {
file := "\tx()\n\ty()\n---\n\t\tx()\n\t\ty()\n"
_, err := textedit.MatchIgnoringIndentation(strings.NewReader(file), "x()\ny()")
fmt.Println(err)
}
Output: old_string not found in file: ignoring indentation it matches at least 2 regions (byte offsets 0 and 14); include more surrounding context to make it unique
type Shift ¶
Shift describes how the file's leading whitespace relates to old_string's on every non-blank line of a match: Add means the file carries Whitespace in addition to old_string's indentation; otherwise old_string carries it in addition to the file's. A zero Shift means the lines matched exactly apart from line endings.
func (Shift) String ¶
String renders the shift for a tool result, for example "added 1 tab", "removed 2 spaces", or "" for the zero shift. Mixed whitespace renders with %q.
Example ¶
package main
import (
"fmt"
"chainguard.dev/driftlessaf/internal/textedit"
)
func main() {
fmt.Printf("%q\n", textedit.Shift{}.String())
fmt.Println(textedit.Shift{Add: true, Whitespace: "\t"})
fmt.Println(textedit.Shift{Whitespace: " "})
fmt.Println(textedit.Shift{Add: true, Whitespace: " \t"})
}
Output: "" added 1 tab removed 4 spaces added " \t"