edit.go

  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}