Add read_range, write_range, and search_replace tools
This commit is contained in:
281
tool_search_replace.go
Normal file
281
tool_search_replace.go
Normal file
@@ -0,0 +1,281 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
func searchReplace(paths []string, pattern string, replacement *string, useRegex, caseInsensitive bool, includeContext bool) (*SearchReplaceResult, error) {
|
||||
result := &SearchReplaceResult{
|
||||
Files: []FileSearchReplaceResult{},
|
||||
}
|
||||
|
||||
// Prepare search/replace function
|
||||
var searchFunc func(string) [][]int
|
||||
var replaceFunc func(string) string
|
||||
|
||||
if useRegex {
|
||||
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 {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
fileResult := processFile(filePath, searchFunc, replaceFunc, includeContext)
|
||||
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
|
||||
fileResult := processFile(path, searchFunc, replaceFunc, includeContext)
|
||||
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
|
||||
}
|
||||
|
||||
func processFile(path string, searchFunc func(string) [][]int, replaceFunc func(string) string, includeContext bool) FileSearchReplaceResult {
|
||||
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)
|
||||
result.Replaced = len(matches)
|
||||
|
||||
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
|
||||
|
||||
searchMatch := SearchMatch{
|
||||
Line: lineNum,
|
||||
Column: column,
|
||||
Text: content[match[0]:match[1]],
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user