1package tools
2
3import (
4 "context"
5 _ "embed"
6 "fmt"
7 "log/slog"
8 "os"
9 "path/filepath"
10 "regexp"
11 "strings"
12 "time"
13
14 "charm.land/fantasy"
15 "github.com/charmbracelet/crush/internal/csync"
16 "github.com/charmbracelet/crush/internal/diff"
17 "github.com/charmbracelet/crush/internal/filepathext"
18 "github.com/charmbracelet/crush/internal/filetracker"
19 "github.com/charmbracelet/crush/internal/fsext"
20 "github.com/charmbracelet/crush/internal/history"
21
22 "github.com/charmbracelet/crush/internal/lsp"
23 "github.com/charmbracelet/crush/internal/permission"
24)
25
26type EditParams struct {
27 FilePath string `json:"file_path" description:"The absolute path to the file to modify"`
28 OldString string `json:"old_string" description:"The text to replace"`
29 NewString string `json:"new_string" description:"The text to replace it with"`
30 ReplaceAll bool `json:"replace_all,omitempty" description:"Replace all occurrences of old_string (default false)"`
31}
32
33type EditPermissionsParams struct {
34 FilePath string `json:"file_path"`
35 OldContent string `json:"old_content,omitempty"`
36 NewContent string `json:"new_content,omitempty"`
37}
38
39type EditResponseMetadata struct {
40 Additions int `json:"additions"`
41 Removals int `json:"removals"`
42 OldContent string `json:"old_content,omitempty"`
43 NewContent string `json:"new_content,omitempty"`
44}
45
46const EditToolName = "edit"
47
48const (
49 errOldStringNotFound = "old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"
50 errOldStringMultipleHits = "old_string appears multiple times in the file. Please provide more context to ensure a unique match, or set replace_all to true"
51)
52
53var (
54 viewLinePrefixRE = regexp.MustCompile(`^\s*\d+\|\s?`)
55 collapseBlankLinesRE = regexp.MustCompile(`\n{3,}`)
56 markdownCodeFenceRE = regexp.MustCompile("(?s)^\\s*```[^\\n]*\\n(.*)\\n```\\s*$")
57)
58
59//go:embed edit.md
60var editDescription []byte
61
62type editContext struct {
63 ctx context.Context
64 permissions permission.Service
65 files history.Service
66 workingDir string
67}
68
69func NewEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) fantasy.AgentTool {
70 return fantasy.NewAgentTool(
71 EditToolName,
72 string(editDescription),
73 func(ctx context.Context, params EditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
74 if params.FilePath == "" {
75 return fantasy.NewTextErrorResponse("file_path is required"), nil
76 }
77
78 params.FilePath = filepathext.SmartJoin(workingDir, params.FilePath)
79
80 var response fantasy.ToolResponse
81 var err error
82
83 editCtx := editContext{ctx, permissions, files, workingDir}
84
85 if params.OldString == "" {
86 response, err = createNewFile(editCtx, params.FilePath, params.NewString, call)
87 } else if params.NewString == "" {
88 response, err = deleteContent(editCtx, params.FilePath, params.OldString, params.ReplaceAll, call)
89 } else {
90 response, err = replaceContent(editCtx, params.FilePath, params.OldString, params.NewString, params.ReplaceAll, call)
91 }
92
93 if err != nil {
94 return response, err
95 }
96 if response.IsError {
97 // Return early if there was an error during content replacement
98 // This prevents unnecessary LSP diagnostics processing
99 return response, nil
100 }
101
102 notifyLSPs(ctx, lspClients, params.FilePath)
103
104 text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
105 text += getDiagnostics(params.FilePath, lspClients)
106 response.Content = text
107 return response, nil
108 })
109}
110
111func createNewFile(edit editContext, filePath, content string, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
112 fileInfo, err := os.Stat(filePath)
113 if err == nil {
114 if fileInfo.IsDir() {
115 return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
116 }
117 return fantasy.NewTextErrorResponse(fmt.Sprintf("file already exists: %s", filePath)), nil
118 } else if !os.IsNotExist(err) {
119 return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
120 }
121
122 dir := filepath.Dir(filePath)
123 if err = os.MkdirAll(dir, 0o755); err != nil {
124 return fantasy.ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
125 }
126
127 sessionID := GetSessionFromContext(edit.ctx)
128 if sessionID == "" {
129 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
130 }
131
132 _, additions, removals := diff.GenerateDiff(
133 "",
134 content,
135 strings.TrimPrefix(filePath, edit.workingDir),
136 )
137 p, err := edit.permissions.Request(edit.ctx,
138 permission.CreatePermissionRequest{
139 SessionID: sessionID,
140 Path: fsext.PathOrPrefix(filePath, edit.workingDir),
141 ToolCallID: call.ID,
142 ToolName: EditToolName,
143 Action: "write",
144 Description: fmt.Sprintf("Create file %s", filePath),
145 Params: EditPermissionsParams{
146 FilePath: filePath,
147 OldContent: "",
148 NewContent: content,
149 },
150 },
151 )
152 if err != nil {
153 return fantasy.ToolResponse{}, err
154 }
155 if !p {
156 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
157 }
158
159 err = os.WriteFile(filePath, []byte(content), 0o644)
160 if err != nil {
161 return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
162 }
163
164 // File can't be in the history so we create a new file history
165 _, err = edit.files.Create(edit.ctx, sessionID, filePath, "")
166 if err != nil {
167 // Log error but don't fail the operation
168 return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
169 }
170
171 // Add the new content to the file history
172 _, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, content)
173 if err != nil {
174 // Log error but don't fail the operation
175 slog.Error("Error creating file history version", "error", err)
176 }
177
178 filetracker.RecordWrite(filePath)
179 filetracker.RecordRead(filePath)
180
181 return fantasy.WithResponseMetadata(
182 fantasy.NewTextResponse("File created: "+filePath),
183 EditResponseMetadata{
184 OldContent: "",
185 NewContent: content,
186 Additions: additions,
187 Removals: removals,
188 },
189 ), nil
190}
191
192func deleteContent(edit editContext, filePath, oldString string, replaceAll bool, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
193 fileInfo, err := os.Stat(filePath)
194 if err != nil {
195 if os.IsNotExist(err) {
196 return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
197 }
198 return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
199 }
200
201 if fileInfo.IsDir() {
202 return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
203 }
204
205 if filetracker.LastReadTime(filePath).IsZero() {
206 return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
207 }
208
209 modTime := fileInfo.ModTime()
210 lastRead := filetracker.LastReadTime(filePath)
211 if modTime.After(lastRead) {
212 return fantasy.NewTextErrorResponse(
213 fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
214 filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
215 )), nil
216 }
217
218 content, err := os.ReadFile(filePath)
219 if err != nil {
220 return fantasy.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
221 }
222
223 oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
224
225 var newContent string
226
227 if replaceAll {
228 // For replaceAll, try fuzzy match if exact match fails.
229 replaced, found := replaceAllWithBestMatch(oldContent, oldString, "")
230 if !found {
231 return fantasy.NewTextErrorResponse(errOldStringNotFound), nil
232 }
233 newContent = replaced
234 } else {
235 // Try exact match first, then fuzzy match.
236 matchedString, found, isMultiple := findBestMatch(oldContent, oldString)
237 if !found {
238 return fantasy.NewTextErrorResponse(errOldStringNotFound), nil
239 }
240 if isMultiple {
241 return fantasy.NewTextErrorResponse(errOldStringMultipleHits), nil
242 }
243
244 index := strings.Index(oldContent, matchedString)
245 newContent = oldContent[:index] + oldContent[index+len(matchedString):]
246 }
247
248 sessionID := GetSessionFromContext(edit.ctx)
249
250 if sessionID == "" {
251 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for deleting content")
252 }
253
254 _, additions, removals := diff.GenerateDiff(
255 oldContent,
256 newContent,
257 strings.TrimPrefix(filePath, edit.workingDir),
258 )
259
260 p, err := edit.permissions.Request(edit.ctx,
261 permission.CreatePermissionRequest{
262 SessionID: sessionID,
263 Path: fsext.PathOrPrefix(filePath, edit.workingDir),
264 ToolCallID: call.ID,
265 ToolName: EditToolName,
266 Action: "write",
267 Description: fmt.Sprintf("Delete content from file %s", filePath),
268 Params: EditPermissionsParams{
269 FilePath: filePath,
270 OldContent: oldContent,
271 NewContent: newContent,
272 },
273 },
274 )
275 if err != nil {
276 return fantasy.ToolResponse{}, err
277 }
278 if !p {
279 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
280 }
281
282 if isCrlf {
283 newContent, _ = fsext.ToWindowsLineEndings(newContent)
284 }
285
286 err = os.WriteFile(filePath, []byte(newContent), 0o644)
287 if err != nil {
288 return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
289 }
290
291 // Check if file exists in history
292 file, err := edit.files.GetByPathAndSession(edit.ctx, filePath, sessionID)
293 if err != nil {
294 _, err = edit.files.Create(edit.ctx, sessionID, filePath, oldContent)
295 if err != nil {
296 // Log error but don't fail the operation
297 return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
298 }
299 }
300 if file.Content != oldContent {
301 // User manually changed the content; store an intermediate version
302 _, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, oldContent)
303 if err != nil {
304 slog.Error("Error creating file history version", "error", err)
305 }
306 }
307 // Store the new version
308 _, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, newContent)
309 if err != nil {
310 slog.Error("Error creating file history version", "error", err)
311 }
312
313 filetracker.RecordWrite(filePath)
314 filetracker.RecordRead(filePath)
315
316 return fantasy.WithResponseMetadata(
317 fantasy.NewTextResponse("Content deleted from file: "+filePath),
318 EditResponseMetadata{
319 OldContent: oldContent,
320 NewContent: newContent,
321 Additions: additions,
322 Removals: removals,
323 },
324 ), nil
325}
326
327func replaceContent(edit editContext, filePath, oldString, newString string, replaceAll bool, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
328 fileInfo, err := os.Stat(filePath)
329 if err != nil {
330 if os.IsNotExist(err) {
331 return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
332 }
333 return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
334 }
335
336 if fileInfo.IsDir() {
337 return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
338 }
339
340 if filetracker.LastReadTime(filePath).IsZero() {
341 return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
342 }
343
344 modTime := fileInfo.ModTime()
345 lastRead := filetracker.LastReadTime(filePath)
346 if modTime.After(lastRead) {
347 return fantasy.NewTextErrorResponse(
348 fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
349 filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
350 )), nil
351 }
352
353 content, err := os.ReadFile(filePath)
354 if err != nil {
355 return fantasy.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
356 }
357
358 oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
359
360 var newContent string
361
362 if replaceAll {
363 // For replaceAll, try fuzzy match if exact match fails.
364 replaced, found := replaceAllWithBestMatch(oldContent, oldString, newString)
365 if !found {
366 return fantasy.NewTextErrorResponse(errOldStringNotFound), nil
367 }
368 newContent = replaced
369 } else {
370 // Try exact match first, then fuzzy match.
371 matchedString, found, isMultiple := findBestMatch(oldContent, oldString)
372 if !found {
373 return fantasy.NewTextErrorResponse(errOldStringNotFound), nil
374 }
375 if isMultiple {
376 return fantasy.NewTextErrorResponse(errOldStringMultipleHits), nil
377 }
378
379 index := strings.Index(oldContent, matchedString)
380 newContent = oldContent[:index] + newString + oldContent[index+len(matchedString):]
381 }
382
383 if oldContent == newContent {
384 return fantasy.NewTextErrorResponse("new content is the same as old content. No changes made."), nil
385 }
386 sessionID := GetSessionFromContext(edit.ctx)
387
388 if sessionID == "" {
389 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
390 }
391 _, additions, removals := diff.GenerateDiff(
392 oldContent,
393 newContent,
394 strings.TrimPrefix(filePath, edit.workingDir),
395 )
396
397 p, err := edit.permissions.Request(edit.ctx,
398 permission.CreatePermissionRequest{
399 SessionID: sessionID,
400 Path: fsext.PathOrPrefix(filePath, edit.workingDir),
401 ToolCallID: call.ID,
402 ToolName: EditToolName,
403 Action: "write",
404 Description: fmt.Sprintf("Replace content in file %s", filePath),
405 Params: EditPermissionsParams{
406 FilePath: filePath,
407 OldContent: oldContent,
408 NewContent: newContent,
409 },
410 },
411 )
412 if err != nil {
413 return fantasy.ToolResponse{}, err
414 }
415 if !p {
416 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
417 }
418
419 if isCrlf {
420 newContent, _ = fsext.ToWindowsLineEndings(newContent)
421 }
422
423 err = os.WriteFile(filePath, []byte(newContent), 0o644)
424 if err != nil {
425 return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
426 }
427
428 // Check if file exists in history
429 file, err := edit.files.GetByPathAndSession(edit.ctx, filePath, sessionID)
430 if err != nil {
431 _, err = edit.files.Create(edit.ctx, sessionID, filePath, oldContent)
432 if err != nil {
433 // Log error but don't fail the operation
434 return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
435 }
436 }
437 if file.Content != oldContent {
438 // User manually changed the content; store an intermediate version
439 _, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, oldContent)
440 if err != nil {
441 slog.Debug("Error creating file history version", "error", err)
442 }
443 }
444 // Store the new version
445 _, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, newContent)
446 if err != nil {
447 slog.Error("Error creating file history version", "error", err)
448 }
449
450 filetracker.RecordWrite(filePath)
451 filetracker.RecordRead(filePath)
452
453 return fantasy.WithResponseMetadata(
454 fantasy.NewTextResponse("Content replaced in file: "+filePath),
455 EditResponseMetadata{
456 OldContent: oldContent,
457 NewContent: newContent,
458 Additions: additions,
459 Removals: removals,
460 }), nil
461}
462
463// findBestMatch attempts to find a match for oldString in content. If an exact
464// match is found, it returns the oldString unchanged. Otherwise, it tries
465// several normalization strategies to find a fuzzy match.
466//
467// Returns: (matchedString, found, isMultiple)
468// - matchedString: the actual string found in content that should be used
469// - found: whether any match was found
470// - isMultiple: whether multiple matches were found (ambiguous)
471func findBestMatch(content, oldString string) (string, bool, bool) {
472 oldString = normalizeOldStringForMatching(oldString)
473
474 // Strategy 1: Exact match.
475 index := strings.Index(content, oldString)
476 if index != -1 {
477 lastIndex := strings.LastIndex(content, oldString)
478 return oldString, true, index != lastIndex
479 }
480
481 // Strategy 2: Try trimming surrounding blank lines.
482 trimmedSurrounding := trimSurroundingBlankLines(oldString)
483 if trimmedSurrounding != "" && trimmedSurrounding != oldString {
484 index := strings.Index(content, trimmedSurrounding)
485 if index != -1 {
486 lastIndex := strings.LastIndex(content, trimmedSurrounding)
487 return trimmedSurrounding, true, index != lastIndex
488 }
489 }
490
491 // Strategy 3: Try trimming trailing whitespace from each line of oldString.
492 trimmedLines := trimTrailingWhitespacePerLine(oldString)
493 if trimmedLines != oldString {
494 index := strings.Index(content, trimmedLines)
495 if index != -1 {
496 lastIndex := strings.LastIndex(content, trimmedLines)
497 return trimmedLines, true, index != lastIndex
498 }
499 }
500
501 // Strategy 4: Try with/without trailing newline.
502 if strings.HasSuffix(oldString, "\n") {
503 withoutTrailing := strings.TrimSuffix(oldString, "\n")
504 index := strings.Index(content, withoutTrailing)
505 if index != -1 {
506 lastIndex := strings.LastIndex(content, withoutTrailing)
507 return withoutTrailing, true, index != lastIndex
508 }
509 } else {
510 withTrailing := oldString + "\n"
511 index := strings.Index(content, withTrailing)
512 if index != -1 {
513 lastIndex := strings.LastIndex(content, withTrailing)
514 return withTrailing, true, index != lastIndex
515 }
516 }
517
518 // Strategy 5: Try matching with flexible blank lines (collapse multiple
519 // blank lines to single).
520 collapsedOld := collapseBlankLines(oldString)
521 if collapsedOld != oldString {
522 index := strings.Index(content, collapsedOld)
523 if index != -1 {
524 lastIndex := strings.LastIndex(content, collapsedOld)
525 return collapsedOld, true, index != lastIndex
526 }
527 }
528
529 // Strategy 6: Try normalizing indentation (find content with same structure
530 // but different leading whitespace).
531 matched, found, isMultiple := tryNormalizeIndentation(content, oldString)
532 if found {
533 return matched, true, isMultiple
534 }
535
536 if collapsedOld != oldString {
537 matched, found, isMultiple := tryNormalizeIndentation(content, collapsedOld)
538 if found {
539 return matched, true, isMultiple
540 }
541 }
542
543 return "", false, false
544}
545
546func normalizeOldStringForMatching(oldString string) string {
547 oldString, _ = fsext.ToUnixLineEndings(oldString)
548 oldString = stripZeroWidthCharacters(oldString)
549 oldString = stripMarkdownCodeFences(oldString)
550 oldString = stripViewLineNumbers(oldString)
551 return oldString
552}
553
554func stripZeroWidthCharacters(s string) string {
555 s = strings.ReplaceAll(s, "\ufeff", "")
556 s = strings.ReplaceAll(s, "\u200b", "")
557 s = strings.ReplaceAll(s, "\u200c", "")
558 s = strings.ReplaceAll(s, "\u200d", "")
559 s = strings.ReplaceAll(s, "\u2060", "")
560 return s
561}
562
563func stripMarkdownCodeFences(s string) string {
564 m := markdownCodeFenceRE.FindStringSubmatch(s)
565 if len(m) != 2 {
566 return s
567 }
568 return m[1]
569}
570
571func stripViewLineNumbers(s string) string {
572 lines := strings.Split(s, "\n")
573 if len(lines) < 2 {
574 return s
575 }
576
577 var withPrefix int
578 for _, line := range lines {
579 if viewLinePrefixRE.MatchString(line) {
580 withPrefix++
581 }
582 }
583
584 if withPrefix < (len(lines)+1)/2 {
585 return s
586 }
587
588 for i, line := range lines {
589 lines[i] = viewLinePrefixRE.ReplaceAllString(line, "")
590 }
591
592 return strings.Join(lines, "\n")
593}
594
595func trimSurroundingBlankLines(s string) string {
596 lines := strings.Split(s, "\n")
597 start := 0
598 for start < len(lines) && strings.TrimSpace(lines[start]) == "" {
599 start++
600 }
601
602 end := len(lines)
603 for end > start && strings.TrimSpace(lines[end-1]) == "" {
604 end--
605 }
606
607 return strings.Join(lines[start:end], "\n")
608}
609
610// replaceAllWithBestMatch replaces all occurrences of oldString in content
611// with newString, using fuzzy matching strategies if an exact match fails.
612func replaceAllWithBestMatch(content, oldString, newString string) (string, bool) {
613 oldString = normalizeOldStringForMatching(oldString)
614 if oldString == "" {
615 return "", false
616 }
617
618 if strings.Contains(content, oldString) {
619 return strings.ReplaceAll(content, oldString, newString), true
620 }
621
622 newContent, ok := tryReplaceAllWithFlexibleMultilineRegexp(content, oldString, newString)
623 if ok {
624 return newContent, true
625 }
626
627 collapsedOld := collapseBlankLines(oldString)
628 if collapsedOld != oldString {
629 newContent, ok := tryReplaceAllWithFlexibleMultilineRegexp(content, collapsedOld, newString)
630 if ok {
631 return newContent, true
632 }
633 }
634
635 matchedString, found, _ := findBestMatch(content, oldString)
636 if !found || matchedString == "" {
637 return "", false
638 }
639 return strings.ReplaceAll(content, matchedString, newString), true
640}
641
642func tryReplaceAllWithFlexibleMultilineRegexp(content, oldString, newString string) (string, bool) {
643 re := buildFlexibleMultilineRegexp(oldString)
644 if re == nil {
645 return "", false
646 }
647
648 if !re.MatchString(content) {
649 return "", false
650 }
651
652 newContent := re.ReplaceAllStringFunc(content, func(string) string {
653 return newString
654 })
655 return newContent, true
656}
657
658func buildFlexibleMultilineRegexp(oldString string) *regexp.Regexp {
659 oldString = normalizeOldStringForMatching(oldString)
660 lines := strings.Split(oldString, "\n")
661 if len(lines) > 0 && lines[len(lines)-1] == "" {
662 lines = lines[:len(lines)-1]
663 }
664 if len(lines) < 2 {
665 return nil
666 }
667
668 patternParts := make([]string, 0, len(lines))
669 for _, line := range lines {
670 trimmedLeft := strings.TrimLeft(line, " \t")
671 trimmed := strings.TrimRight(trimmedLeft, " \t")
672 if trimmed == "" {
673 patternParts = append(patternParts, `^[ \t]*$`)
674 continue
675 }
676 escaped := regexp.QuoteMeta(trimmed)
677 patternParts = append(patternParts, `^[ \t]*`+escaped+`[ \t]*$`)
678 }
679
680 pattern := "(?m)" + strings.Join(patternParts, "\n")
681 re, err := regexp.Compile(pattern)
682 if err != nil {
683 return nil
684 }
685 return re
686}
687
688// trimTrailingWhitespacePerLine removes trailing spaces/tabs from each line.
689func trimTrailingWhitespacePerLine(s string) string {
690 lines := strings.Split(s, "\n")
691 for i, line := range lines {
692 lines[i] = strings.TrimRight(line, " \t")
693 }
694 return strings.Join(lines, "\n")
695}
696
697// collapseBlankLines replaces multiple consecutive blank lines with a single
698// blank line.
699func collapseBlankLines(s string) string {
700 return collapseBlankLinesRE.ReplaceAllString(s, "\n\n")
701}
702
703// tryNormalizeIndentation attempts to find a match by adjusting indentation.
704// It extracts the "shape" of the code (non-whitespace content per line) and
705// looks for that pattern in the content with potentially different
706// indentation.
707func tryNormalizeIndentation(content, oldString string) (string, bool, bool) {
708 re := buildFlexibleMultilineRegexp(oldString)
709 if re == nil {
710 return "", false, false
711 }
712
713 matches := re.FindAllStringIndex(content, 2)
714 if len(matches) == 0 {
715 return "", false, false
716 }
717 if len(matches) > 1 {
718 return content[matches[0][0]:matches[0][1]], true, true
719 }
720 return content[matches[0][0]:matches[0][1]], true, false
721}