2025-06-27 21:35:37 -07:00
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"fmt"
|
|
|
|
|
"io/fs"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"regexp"
|
|
|
|
|
"strings"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type SearchReplaceResult struct {
|
|
|
|
|
Files []FileSearchReplaceResult `json:"files"`
|
|
|
|
|
TotalMatches int `json:"total_matches"`
|
|
|
|
|
TotalReplaced int `json:"total_replaced,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type FileSearchReplaceResult struct {
|
|
|
|
|
Path string `json:"path"`
|
|
|
|
|
Matches []SearchMatch `json:"matches,omitempty"`
|
|
|
|
|
Replaced int `json:"replaced,omitempty"`
|
|
|
|
|
Error string `json:"error,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type SearchMatch struct {
|
|
|
|
|
Line int `json:"line"`
|
|
|
|
|
Column int `json:"column"`
|
|
|
|
|
Text string `json:"text"`
|
|
|
|
|
Context string `json:"context,omitempty"`
|
2025-06-27 22:20:01 -07:00
|
|
|
StartLine int `json:"start_line"`
|
|
|
|
|
StartCol int `json:"start_col"`
|
|
|
|
|
EndLine int `json:"end_line"`
|
|
|
|
|
EndCol int `json:"end_col"`
|
|
|
|
|
StartByte int `json:"start_byte"`
|
|
|
|
|
EndByte int `json:"end_byte"`
|
2025-06-27 21:35:37 -07:00
|
|
|
}
|
|
|
|
|
|
2025-06-27 22:25:26 -07:00
|
|
|
func searchReplace(paths []string, pattern string, replacement *string, useRegex, caseInsensitive bool, includeContext bool, beforePattern, afterPattern string, replaceAll bool) (*SearchReplaceResult, error) {
|
2025-06-27 21:35:37 -07:00
|
|
|
result := &SearchReplaceResult{
|
|
|
|
|
Files: []FileSearchReplaceResult{},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Prepare search/replace function
|
|
|
|
|
var searchFunc func(string) [][]int
|
|
|
|
|
var replaceFunc func(string) string
|
|
|
|
|
|
2025-06-27 22:08:12 -07:00
|
|
|
// Handle context-aware replacement with before/after patterns
|
|
|
|
|
if (beforePattern != "" || afterPattern != "") && replacement != nil {
|
|
|
|
|
// Build a pattern that captures before, target, and after parts
|
|
|
|
|
contextPattern := ""
|
|
|
|
|
if beforePattern != "" {
|
|
|
|
|
contextPattern += "(" + regexp.QuoteMeta(beforePattern) + ")"
|
|
|
|
|
}
|
|
|
|
|
if useRegex {
|
|
|
|
|
contextPattern += "(" + pattern + ")"
|
|
|
|
|
} else {
|
|
|
|
|
contextPattern += "(" + regexp.QuoteMeta(pattern) + ")"
|
|
|
|
|
}
|
|
|
|
|
if afterPattern != "" {
|
|
|
|
|
contextPattern += "(" + regexp.QuoteMeta(afterPattern) + ")"
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-27 21:38:51 -07:00
|
|
|
flags := ""
|
|
|
|
|
if caseInsensitive {
|
|
|
|
|
flags = "(?i)"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
contextRe, err := regexp.Compile(flags + contextPattern)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("invalid context pattern: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
searchFunc = func(text string) [][]int {
|
|
|
|
|
return contextRe.FindAllStringIndex(text, -1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
replaceFunc = func(text string) string {
|
2025-06-27 22:25:26 -07:00
|
|
|
if !replaceAll {
|
|
|
|
|
// Replace only first occurrence
|
|
|
|
|
loc := contextRe.FindStringSubmatchIndex(text)
|
|
|
|
|
if loc == nil {
|
|
|
|
|
return text
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
submatches := contextRe.FindStringSubmatch(text)
|
|
|
|
|
// Rebuild the match with the target replaced
|
|
|
|
|
result := ""
|
|
|
|
|
if beforePattern != "" && len(submatches) > 1 {
|
|
|
|
|
result += submatches[1] // before part
|
|
|
|
|
}
|
|
|
|
|
result += *replacement // replacement for target
|
|
|
|
|
if afterPattern != "" {
|
|
|
|
|
// The after part is at index 3 if before exists, otherwise at index 2
|
|
|
|
|
afterIndex := 2
|
|
|
|
|
if beforePattern != "" {
|
|
|
|
|
afterIndex = 3
|
|
|
|
|
}
|
|
|
|
|
if len(submatches) > afterIndex {
|
|
|
|
|
result += submatches[afterIndex]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return text[:loc[0]] + result + text[loc[1]:]
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-27 21:38:51 -07:00
|
|
|
return contextRe.ReplaceAllStringFunc(text, func(match string) string {
|
|
|
|
|
submatches := contextRe.FindStringSubmatch(match)
|
|
|
|
|
|
2025-06-27 22:08:12 -07:00
|
|
|
// Rebuild the match with the target replaced
|
2025-06-27 21:38:51 -07:00
|
|
|
result := ""
|
2025-06-27 22:08:12 -07:00
|
|
|
if beforePattern != "" && len(submatches) > 1 {
|
|
|
|
|
result += submatches[1] // before part
|
|
|
|
|
}
|
|
|
|
|
result += *replacement // replacement for target
|
|
|
|
|
if afterPattern != "" {
|
|
|
|
|
// The after part is at index 3 if before exists, otherwise at index 2
|
|
|
|
|
afterIndex := 2
|
|
|
|
|
if beforePattern != "" {
|
|
|
|
|
afterIndex = 3
|
|
|
|
|
}
|
|
|
|
|
if len(submatches) > afterIndex {
|
|
|
|
|
result += submatches[afterIndex]
|
2025-06-27 21:38:51 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return result
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
} else if useRegex {
|
2025-06-27 21:35:37 -07:00
|
|
|
flags := ""
|
|
|
|
|
if caseInsensitive {
|
|
|
|
|
flags = "(?i)"
|
|
|
|
|
}
|
|
|
|
|
re, err := regexp.Compile(flags + pattern)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("invalid regex pattern: %w", err)
|
|
|
|
|
}
|
|
|
|
|
searchFunc = func(text string) [][]int {
|
|
|
|
|
return re.FindAllStringIndex(text, -1)
|
|
|
|
|
}
|
|
|
|
|
if replacement != nil {
|
|
|
|
|
replaceFunc = func(text string) string {
|
2025-06-27 22:25:26 -07:00
|
|
|
if !replaceAll {
|
|
|
|
|
// Replace only first occurrence
|
|
|
|
|
loc := re.FindStringIndex(text)
|
|
|
|
|
if loc == nil {
|
|
|
|
|
return text
|
|
|
|
|
}
|
|
|
|
|
return text[:loc[0]] + string(re.ExpandString([]byte{}, *replacement, text, re.FindStringSubmatchIndex(text))) + text[loc[1]:]
|
|
|
|
|
}
|
2025-06-27 21:35:37 -07:00
|
|
|
return re.ReplaceAllString(text, *replacement)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
searchPattern := pattern
|
|
|
|
|
if caseInsensitive {
|
|
|
|
|
searchPattern = strings.ToLower(pattern)
|
|
|
|
|
}
|
|
|
|
|
searchFunc = func(text string) [][]int {
|
|
|
|
|
searchText := text
|
|
|
|
|
if caseInsensitive {
|
|
|
|
|
searchText = strings.ToLower(text)
|
|
|
|
|
}
|
|
|
|
|
var matches [][]int
|
|
|
|
|
start := 0
|
|
|
|
|
for {
|
|
|
|
|
idx := strings.Index(searchText[start:], searchPattern)
|
|
|
|
|
if idx < 0 {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
realIdx := start + idx
|
|
|
|
|
matches = append(matches, []int{realIdx, realIdx + len(pattern)})
|
|
|
|
|
start = realIdx + len(pattern)
|
|
|
|
|
}
|
|
|
|
|
return matches
|
|
|
|
|
}
|
|
|
|
|
if replacement != nil {
|
|
|
|
|
replaceFunc = func(text string) string {
|
2025-06-27 22:25:26 -07:00
|
|
|
if !replaceAll {
|
|
|
|
|
// Replace only first occurrence
|
|
|
|
|
if caseInsensitive {
|
|
|
|
|
return caseInsensitiveReplaceFirst(text, pattern, *replacement)
|
|
|
|
|
}
|
|
|
|
|
return strings.Replace(text, pattern, *replacement, 1)
|
|
|
|
|
}
|
2025-06-27 21:35:37 -07:00
|
|
|
if caseInsensitive {
|
|
|
|
|
// Case-insensitive string replacement
|
|
|
|
|
return caseInsensitiveReplace(text, pattern, *replacement)
|
|
|
|
|
}
|
|
|
|
|
return strings.ReplaceAll(text, pattern, *replacement)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, path := range paths {
|
|
|
|
|
info, err := os.Stat(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
result.Files = append(result.Files, FileSearchReplaceResult{
|
|
|
|
|
Path: path,
|
|
|
|
|
Error: fmt.Sprintf("stat error: %v", err),
|
|
|
|
|
})
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if info.IsDir() {
|
|
|
|
|
// Process directory tree
|
|
|
|
|
err := filepath.WalkDir(path, func(filePath string, d fs.DirEntry, err error) error {
|
|
|
|
|
if err != nil || d.IsDir() {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Skip non-text files
|
|
|
|
|
if !isTextFile(filePath) {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-27 22:25:26 -07:00
|
|
|
fileResult := processFile(filePath, searchFunc, replaceFunc, includeContext, replaceAll)
|
2025-06-27 21:35:37 -07:00
|
|
|
if len(fileResult.Matches) > 0 || fileResult.Replaced > 0 || fileResult.Error != "" {
|
|
|
|
|
result.Files = append(result.Files, fileResult)
|
|
|
|
|
result.TotalMatches += len(fileResult.Matches)
|
|
|
|
|
result.TotalReplaced += fileResult.Replaced
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Process single file
|
2025-06-27 22:25:26 -07:00
|
|
|
fileResult := processFile(path, searchFunc, replaceFunc, includeContext, replaceAll)
|
2025-06-27 21:35:37 -07:00
|
|
|
if len(fileResult.Matches) > 0 || fileResult.Replaced > 0 || fileResult.Error != "" {
|
|
|
|
|
result.Files = append(result.Files, fileResult)
|
|
|
|
|
result.TotalMatches += len(fileResult.Matches)
|
|
|
|
|
result.TotalReplaced += fileResult.Replaced
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result, nil
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-27 21:38:51 -07:00
|
|
|
|
2025-06-27 22:25:26 -07:00
|
|
|
func processFile(path string, searchFunc func(string) [][]int, replaceFunc func(string) string, includeContext bool, replaceAll bool) FileSearchReplaceResult {
|
2025-06-27 21:35:37 -07:00
|
|
|
result := FileSearchReplaceResult{
|
|
|
|
|
Path: path,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Read file
|
|
|
|
|
data, err := os.ReadFile(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
result.Error = fmt.Sprintf("read error: %v", err)
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
content := string(data)
|
|
|
|
|
|
|
|
|
|
// If replacement is requested, do it
|
|
|
|
|
if replaceFunc != nil {
|
|
|
|
|
matches := searchFunc(content)
|
2025-06-27 22:25:26 -07:00
|
|
|
if replaceAll {
|
|
|
|
|
result.Replaced = len(matches)
|
|
|
|
|
} else {
|
|
|
|
|
// Only replacing first occurrence
|
|
|
|
|
if len(matches) > 0 {
|
|
|
|
|
result.Replaced = 1
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-06-27 21:35:37 -07:00
|
|
|
|
|
|
|
|
if result.Replaced > 0 {
|
|
|
|
|
newContent := replaceFunc(content)
|
|
|
|
|
err = os.WriteFile(path, []byte(newContent), 0644)
|
|
|
|
|
if err != nil {
|
|
|
|
|
result.Error = fmt.Sprintf("write error: %v", err)
|
|
|
|
|
result.Replaced = 0
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Otherwise, just search
|
|
|
|
|
lines := strings.Split(content, "\n")
|
|
|
|
|
lineStarts := make([]int, len(lines))
|
|
|
|
|
pos := 0
|
|
|
|
|
for i, line := range lines {
|
|
|
|
|
lineStarts[i] = pos
|
|
|
|
|
pos += len(line) + 1 // +1 for newline
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
matches := searchFunc(content)
|
|
|
|
|
for _, match := range matches {
|
|
|
|
|
// Find line number
|
|
|
|
|
lineNum := 0
|
|
|
|
|
for i, start := range lineStarts {
|
|
|
|
|
if match[0] >= start && (i == len(lineStarts)-1 || match[0] < lineStarts[i+1]) {
|
|
|
|
|
lineNum = i + 1
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Calculate column
|
|
|
|
|
lineStart := 0
|
|
|
|
|
if lineNum > 0 {
|
|
|
|
|
lineStart = lineStarts[lineNum-1]
|
|
|
|
|
}
|
|
|
|
|
column := match[0] - lineStart + 1
|
|
|
|
|
|
2025-06-27 22:20:01 -07:00
|
|
|
// Calculate end position
|
|
|
|
|
endLineNum := lineNum
|
|
|
|
|
endColumn := column + len(content[match[0]:match[1]])
|
|
|
|
|
|
|
|
|
|
// Check if match spans multiple lines
|
|
|
|
|
matchText := content[match[0]:match[1]]
|
|
|
|
|
newlineCount := strings.Count(matchText, "\n")
|
|
|
|
|
if newlineCount > 0 {
|
|
|
|
|
// Find the end line
|
|
|
|
|
for i := lineNum; i < len(lineStarts); i++ {
|
|
|
|
|
if match[1] <= lineStarts[i] {
|
|
|
|
|
endLineNum = i
|
|
|
|
|
break
|
|
|
|
|
} else if i == len(lineStarts)-1 {
|
|
|
|
|
endLineNum = len(lines)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Calculate end column on the last line
|
|
|
|
|
endLineStart := 0
|
|
|
|
|
if endLineNum > 0 && endLineNum <= len(lineStarts) {
|
|
|
|
|
endLineStart = lineStarts[endLineNum-1]
|
|
|
|
|
}
|
|
|
|
|
endColumn = match[1] - endLineStart + 1
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-27 21:35:37 -07:00
|
|
|
searchMatch := SearchMatch{
|
2025-06-27 22:20:01 -07:00
|
|
|
Line: lineNum,
|
|
|
|
|
Column: column,
|
|
|
|
|
Text: matchText,
|
|
|
|
|
StartLine: lineNum,
|
|
|
|
|
StartCol: column,
|
|
|
|
|
EndLine: endLineNum,
|
|
|
|
|
EndCol: endColumn,
|
|
|
|
|
StartByte: match[0],
|
|
|
|
|
EndByte: match[1],
|
2025-06-27 21:35:37 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if includeContext && lineNum > 0 && lineNum <= len(lines) {
|
|
|
|
|
searchMatch.Context = strings.TrimSpace(lines[lineNum-1])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result.Matches = append(result.Matches, searchMatch)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func caseInsensitiveReplace(text, old, new string) string {
|
|
|
|
|
// Simple case-insensitive replacement
|
|
|
|
|
var result strings.Builder
|
|
|
|
|
lowerText := strings.ToLower(text)
|
|
|
|
|
lowerOld := strings.ToLower(old)
|
|
|
|
|
|
|
|
|
|
start := 0
|
|
|
|
|
for {
|
|
|
|
|
idx := strings.Index(lowerText[start:], lowerOld)
|
|
|
|
|
if idx < 0 {
|
|
|
|
|
result.WriteString(text[start:])
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
realIdx := start + idx
|
|
|
|
|
result.WriteString(text[start:realIdx])
|
|
|
|
|
result.WriteString(new)
|
|
|
|
|
start = realIdx + len(old)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result.String()
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-27 22:25:26 -07:00
|
|
|
func caseInsensitiveReplaceFirst(text, old, new string) string {
|
|
|
|
|
// Case-insensitive replacement for first occurrence only
|
|
|
|
|
lowerText := strings.ToLower(text)
|
|
|
|
|
lowerOld := strings.ToLower(old)
|
|
|
|
|
|
|
|
|
|
idx := strings.Index(lowerText, lowerOld)
|
|
|
|
|
if idx < 0 {
|
|
|
|
|
return text
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return text[:idx] + new + text[idx+len(old):]
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-27 21:35:37 -07:00
|
|
|
func isTextFile(path string) bool {
|
|
|
|
|
ext := strings.ToLower(filepath.Ext(path))
|
|
|
|
|
textExts := map[string]bool{
|
|
|
|
|
".go": true,
|
|
|
|
|
".txt": true,
|
|
|
|
|
".md": true,
|
|
|
|
|
".json": true,
|
|
|
|
|
".yaml": true,
|
|
|
|
|
".yml": true,
|
|
|
|
|
".toml": true,
|
|
|
|
|
".xml": true,
|
|
|
|
|
".html": true,
|
|
|
|
|
".css": true,
|
|
|
|
|
".js": true,
|
|
|
|
|
".ts": true,
|
|
|
|
|
".py": true,
|
|
|
|
|
".rb": true,
|
|
|
|
|
".java": true,
|
|
|
|
|
".c": true,
|
|
|
|
|
".cpp": true,
|
|
|
|
|
".h": true,
|
|
|
|
|
".hpp": true,
|
|
|
|
|
".rs": true,
|
|
|
|
|
".sh": true,
|
|
|
|
|
".bash": true,
|
|
|
|
|
".zsh": true,
|
|
|
|
|
".fish": true,
|
|
|
|
|
".sql": true,
|
|
|
|
|
".proto": true,
|
|
|
|
|
".mod": true,
|
|
|
|
|
".sum": true,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check extension
|
|
|
|
|
if textExts[ext] {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check for files without extension that might be text
|
|
|
|
|
base := filepath.Base(path)
|
|
|
|
|
if base == "Makefile" || base == "Dockerfile" || base == "README" ||
|
|
|
|
|
base == "LICENSE" || base == "CHANGELOG" || base == "TODO" ||
|
|
|
|
|
strings.HasPrefix(base, ".") { // dotfiles are often text
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false
|
|
|
|
|
}
|