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