返回 DeepSeek-Reasonix
delete_range.go
根目录 / internal / tool / builtin / delete_range.go
1 package builtin
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "path/filepath"
8 "strings"
9
10 "reasonix/internal/diff"
11 "reasonix/internal/sandbox"
12 "reasonix/internal/tool"
13 )
14
15 func init() { tool.RegisterBuiltin(deleteRange{}) }
16
17 type deleteRange struct {
18 roots []string
19 rootSet *sandbox.WritableRootSet
20 guard SessionDataGuard
21 managed ManagedConfigPaths
22 workDir string
23 overlay FileOverlay
24 }
25
26 func (deleteRange) Name() string { return "delete_range" }
27
28 func (deleteRange) Description() string {
29 return "Delete a contiguous text range from a file using exact start/end text anchors. Each anchor must match exactly one line. Returns unified diff on success. Use for large deletions — smaller changes should use edit_file."
30 }
31
32 func (deleteRange) Schema() json.RawMessage {
33 return json.RawMessage(`{
34 "type":"object",
35 "properties":{
36 "path":{"type":"string","description":"File path"},
37 "start_anchor":{"type":"string","description":"Exact text of the first line to delete (must be unique in the file)"},
38 "end_anchor":{"type":"string","description":"Exact text of the last line to delete (must be unique in the file)"},
39 "inclusive":{"type":"boolean","description":"Whether to include the anchor lines in the deletion (default true)"}
40 },
41 "required":["path","start_anchor","end_anchor"]
42 }`)
43 }
44
45 func (deleteRange) ReadOnly() bool { return false }
46
47 func (d deleteRange) DeclareWriteAccess(args json.RawMessage) (tool.WriteAccessDeclaration, error) {
48 return declareFilePathWriteAccess(d.workDir, args)
49 }
50
51 func (d deleteRange) Execute(ctx context.Context, args json.RawMessage) (string, error) {
52 var target struct {
53 Path string `json:"path"`
54 }
55 if err := json.Unmarshal(args, &target); err != nil {
56 return "", fmt.Errorf("invalid args: %w", err)
57 }
58 path := resolveIn(d.workDir, target.Path)
59 unlock := lockMutationPath(path)
60 defer unlock()
61 change, src, err := d.preview(ctx, args)
62 if err != nil {
63 return "", err
64 }
65 // preview ran the non-approving boundary check; the actual write needs the
66 // full one, which can gate a Reasonix-managed config target on user approval.
67 if err := confineWrite(ctx, effectiveWriteRoots(ctx, d.rootSet, d.roots), d.guard, d.managed, change.Path); err != nil {
68 return "", err
69 }
70 if err := src.requireObserved(ctx, d.overlay, change.Path); err != nil {
71 return "", err
72 }
73 // src carries the route and encoding the read came from, so the rewrite
74 // returns to the same place and preserves GBK/UTF-16/BOM.
75 if err := src.write(ctx, d.overlay, change.Path, change.NewText); err != nil {
76 return "", fmt.Errorf("write %s: %w", change.Path, err)
77 }
78 return change.Diff, nil
79 }
80
81 func (d deleteRange) Preview(ctx context.Context, args json.RawMessage) (diff.Change, error) {
82 change, _, err := d.preview(ctx, args)
83 return change, err
84 }
85
86 type deleteRangeTarget struct {
87 path string
88 inclusive bool
89 source editSource
90 original string
91 lines []string
92 startLine int
93 endLine int
94 deleteStart int
95 deleteEnd int
96 deletesLines bool
97 }
98
99 func (d deleteRange) resolveTarget(ctx context.Context, args json.RawMessage) (deleteRangeTarget, error) {
100 var p struct {
101 Path string `json:"path"`
102 StartAnchor string `json:"start_anchor"`
103 EndAnchor string `json:"end_anchor"`
104 Inclusive *bool `json:"inclusive"`
105 }
106 if err := json.Unmarshal(args, &p); err != nil {
107 return deleteRangeTarget{}, fmt.Errorf("invalid args: %w", err)
108 }
109 if p.Path == "" {
110 return deleteRangeTarget{}, fmt.Errorf("path is required")
111 }
112 if p.StartAnchor == "" {
113 return deleteRangeTarget{}, fmt.Errorf("start_anchor is required")
114 }
115 if p.EndAnchor == "" {
116 return deleteRangeTarget{}, fmt.Errorf("end_anchor is required")
117 }
118
119 inclusive := true
120 if p.Inclusive != nil {
121 inclusive = *p.Inclusive
122 }
123
124 p.Path = resolveIn(d.workDir, p.Path)
125 if err := confinePreview(effectiveWriteRoots(ctx, d.rootSet, d.roots), d.guard, d.managed, p.Path); err != nil {
126 return deleteRangeTarget{}, err
127 }
128
129 src, err := readEditSource(ctx, d.overlay, p.Path)
130 if err != nil {
131 return deleteRangeTarget{}, fmt.Errorf("read %s: %w", p.Path, err)
132 }
133 original := src.content
134
135 // Strip \r for matching (split on \n after removing \r).
136 lines := strings.Split(strings.ReplaceAll(original, "\r", ""), "\n")
137 startLine := findUniqueLine(lines, p.StartAnchor)
138 if startLine == -2 {
139 return deleteRangeTarget{}, fmt.Errorf("start_anchor is not unique in %s%s; add nearby unique code, not just repeated separator lines", p.Path, lineMatchSummary(lines, p.StartAnchor, 5))
140 }
141 if startLine == -1 {
142 return deleteRangeTarget{}, fmt.Errorf("start_anchor not found in %s", p.Path)
143 }
144 endLine := findUniqueLine(lines, p.EndAnchor)
145 if endLine == -2 {
146 return deleteRangeTarget{}, fmt.Errorf("end_anchor is not unique in %s%s; add nearby unique code, not just repeated separator lines", p.Path, lineMatchSummary(lines, p.EndAnchor, 5))
147 }
148 if endLine == -1 {
149 return deleteRangeTarget{}, fmt.Errorf("end_anchor not found in %s", p.Path)
150 }
151 if startLine > endLine {
152 return deleteRangeTarget{}, fmt.Errorf("start_anchor appears after end_anchor (lines %d and %d)", startLine+1, endLine+1)
153 }
154 deleteStart, deleteEnd, deletesLines, err := deletionLineInterval(startLine, endLine, inclusive, p.Path)
155 if err != nil {
156 return deleteRangeTarget{}, err
157 }
158 if deletesLines && shouldValidateBraceCompleteDeletion(p.Path) {
159 if err := validateBraceCompleteDeletion(lines, deleteStart, deleteEnd, p.Path); err != nil {
160 return deleteRangeTarget{}, err
161 }
162 }
163 return deleteRangeTarget{
164 path: p.Path, inclusive: inclusive, source: src, original: original,
165 lines: lines, startLine: startLine, endLine: endLine,
166 deleteStart: deleteStart, deleteEnd: deleteEnd, deletesLines: deletesLines,
167 }, nil
168 }
169
170 func (d deleteRange) preview(ctx context.Context, args json.RawMessage) (diff.Change, editSource, error) {
171 target, err := d.resolveTarget(ctx, args)
172 if err != nil {
173 return diff.Change{}, editSource{}, err
174 }
175 lineSep := "\n"
176 if strings.Contains(target.original, "\r\n") {
177 lineSep = "\r\n"
178 }
179
180 // Build new content
181 var keep []string
182 if target.inclusive {
183 keep = append(keep, target.lines[:target.startLine]...)
184 keep = append(keep, target.lines[target.endLine+1:]...)
185 } else {
186 // Same line for both anchors: the kept prefix and suffix would overlap at
187 // that line and duplicate it. There is nothing strictly between a line and
188 // itself, so the exclusive deletion is contradictory — reject it.
189 if target.startLine == target.endLine {
190 return diff.Change{}, editSource{}, fmt.Errorf("start_anchor and end_anchor match the same line in %s; with inclusive=false there is nothing between them to delete", target.path)
191 }
192 keep = append(keep, target.lines[:target.startLine+1]...)
193 keep = append(keep, target.lines[target.endLine:]...)
194 }
195 newContent := strings.Join(keep, lineSep)
196 // Preserve trailing newline if original had one.
197 if newContent != "" && strings.HasSuffix(target.original, lineSep) && !strings.HasSuffix(newContent, lineSep) {
198 newContent += lineSep
199 }
200
201 return diff.Build(target.path, target.original, newContent, diff.Modify), target.source, nil
202 }
203
204 // findUniqueLine returns the index of the line that equals target.
205 // Returns -1 if not found, -2 if found on multiple lines.
206 func findUniqueLine(lines []string, target string) int {
207 idx := -1
208 for i, l := range lines {
209 if l == target {
210 if idx >= 0 {
211 return -2
212 }
213 idx = i
214 }
215 }
216 return idx
217 }
218
219 func deletionLineInterval(startLine, endLine int, inclusive bool, path string) (int, int, bool, error) {
220 if inclusive {
221 return startLine, endLine, true, nil
222 }
223 if startLine == endLine {
224 return 0, 0, false, fmt.Errorf("start_anchor and end_anchor match the same line in %s; with inclusive=false there is nothing between them to delete", path)
225 }
226 start, end := startLine+1, endLine-1
227 if start > end {
228 return start, end, false, nil
229 }
230 return start, end, true, nil
231 }
232
233 func shouldValidateBraceCompleteDeletion(path string) bool {
234 switch strings.ToLower(filepath.Ext(path)) {
235 case ".c", ".cc", ".cjs", ".cpp", ".cs", ".css", ".cxx",
236 ".go", ".h", ".hh", ".hpp", ".htm", ".html",
237 ".java", ".js", ".json", ".jsonc", ".jsx", ".kt", ".kts",
238 ".less", ".mjs", ".php", ".rs", ".sass", ".scss", ".svelte",
239 ".swift", ".ts", ".tsx", ".vue":
240 return true
241 default:
242 return false
243 }
244 }
245
246 func validateBraceCompleteDeletion(lines []string, deleteStart, deleteEnd int, path string) error {
247 for _, pair := range bracePairsByLine(lines) {
248 if pair.openLine < 0 || pair.closeLine < 0 {
249 continue
250 }
251 openDeleted := lineInRange(pair.openLine, deleteStart, deleteEnd)
252 closeDeleted := lineInRange(pair.closeLine, deleteStart, deleteEnd)
253 switch {
254 case openDeleted && !closeDeleted:
255 if pair.openLine == deleteEnd {
256 return fmt.Errorf("end_anchor in %s appears to open a code block at line %d; delete_range would delete that header but leave its closing line %d outside the range. Use an end_anchor on the block's closing line, or use edit_file/multi_edit with the full exact block", path, pair.openLine+1, pair.closeLine+1)
257 }
258 return fmt.Errorf("delete_range in %s would cut a code block: opening brace at line %d is deleted but its closing brace at line %d is kept. Choose anchors that include the whole block, or use edit_file/multi_edit with the full exact block", path, pair.openLine+1, pair.closeLine+1)
259 case !openDeleted && closeDeleted:
260 return fmt.Errorf("delete_range in %s would cut a code block: closing brace at line %d is deleted but its opening brace at line %d is kept. Choose anchors that include the whole block, or use edit_file/multi_edit with the full exact block", path, pair.closeLine+1, pair.openLine+1)
261 }
262 }
263 return nil
264 }
265
266 func lineInRange(line, start, end int) bool {
267 return line >= start && line <= end
268 }
269
270 type bracePair struct {
271 openLine int
272 closeLine int
273 }
274
275 func bracePairsByLine(lines []string) []bracePair {
276 var pairs []bracePair
277 var stack []int
278 inBlockComment := false
279 var quote byte
280 escaped := false
281
282 for lineNo, line := range lines {
283 for i := 0; i < len(line); i++ {
284 c := line[i]
285 if inBlockComment {
286 if c == '*' && i+1 < len(line) && line[i+1] == '/' {
287 inBlockComment = false
288 i++
289 }
290 continue
291 }
292 if quote != 0 {
293 if escaped {
294 escaped = false
295 continue
296 }
297 if c == '\\' {
298 escaped = true
299 continue
300 }
301 if c == quote {
302 quote = 0
303 }
304 continue
305 }
306 if c == '/' && i+1 < len(line) {
307 switch line[i+1] {
308 case '/':
309 i = len(line)
310 continue
311 case '*':
312 inBlockComment = true
313 i++
314 continue
315 }
316 }
317 switch c {
318 case '\'', '"', '`':
319 quote = c
320 case '{':
321 stack = append(stack, lineNo)
322 case '}':
323 if len(stack) == 0 {
324 pairs = append(pairs, bracePair{openLine: -1, closeLine: lineNo})
325 continue
326 }
327 openLine := stack[len(stack)-1]
328 stack = stack[:len(stack)-1]
329 pairs = append(pairs, bracePair{openLine: openLine, closeLine: lineNo})
330 }
331 }
332 if quote == '\'' || quote == '"' {
333 quote = 0
334 escaped = false
335 }
336 }
337 for _, openLine := range stack {
338 pairs = append(pairs, bracePair{openLine: openLine, closeLine: -1})
339 }
340 return pairs
341 }
342
343 func lineMatchSummary(lines []string, target string, limit int) string {
344 var matches []int
345 for i, line := range lines {
346 if line == target {
347 matches = append(matches, i+1)
348 }
349 }
350 if len(matches) == 0 {
351 return ""
352 }
353 var b strings.Builder
354 b.WriteString(" (matching lines include ")
355 for i, line := range matches {
356 if i >= limit {
357 b.WriteString(", ...")
358 break
359 }
360 if i > 0 {
361 b.WriteString(", ")
362 }
363 fmt.Fprint(&b, line)
364 }
365 b.WriteString(")")
366 return b.String()
367 }
368
368 lines GO