181 lines
4.2 KiB
Go
181 lines
4.2 KiB
Go
|
|
package cmd
|
||
|
|
|
||
|
|
import (
|
||
|
|
"bufio"
|
||
|
|
"flag"
|
||
|
|
"fmt"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"regexp"
|
||
|
|
"strings"
|
||
|
|
|
||
|
|
"github.com/zemenawi/zutils/pkg/colors"
|
||
|
|
)
|
||
|
|
|
||
|
|
type SearchOptions struct {
|
||
|
|
Pattern string
|
||
|
|
Path string
|
||
|
|
CaseInsensitive bool
|
||
|
|
WholeWord bool
|
||
|
|
LineNumbers bool
|
||
|
|
ContextLines int
|
||
|
|
FilePattern string
|
||
|
|
}
|
||
|
|
|
||
|
|
type SearchResult struct {
|
||
|
|
File string
|
||
|
|
Line int
|
||
|
|
Content string
|
||
|
|
}
|
||
|
|
|
||
|
|
func SearchCommand(args []string) error {
|
||
|
|
fs := flag.NewFlagSet("search", flag.ContinueOnError)
|
||
|
|
options := SearchOptions{}
|
||
|
|
|
||
|
|
fs.BoolVar(&options.CaseInsensitive, "i", false, "Case insensitive search")
|
||
|
|
fs.BoolVar(&options.WholeWord, "w", false, "Match whole word")
|
||
|
|
fs.BoolVar(&options.LineNumbers, "n", true, "Show line numbers")
|
||
|
|
fs.IntVar(&options.ContextLines, "C", 0, "Show N lines of context")
|
||
|
|
fs.StringVar(&options.FilePattern, "f", "*", "Search only in files matching pattern")
|
||
|
|
|
||
|
|
if err := fs.Parse(args); err != nil {
|
||
|
|
if err == flag.ErrHelp {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
return fmt.Errorf("invalid arguments\n\nUsage: z search [options] pattern [path]\n\nExamples:\n z search func cmd/ Search for 'func' in cmd directory\n z search -i 'error' . Case-insensitive search\n z search -w 'import' *.go Whole word match in Go files")
|
||
|
|
}
|
||
|
|
|
||
|
|
if fs.NArg() < 1 {
|
||
|
|
return fmt.Errorf("missing search pattern\n\nUsage: z search [options] pattern [path]\n\nExamples:\n z search func cmd/ Search for 'func' in cmd directory\n z search -i 'error' . Case-insensitive search\n z search -w 'import' *.go Whole word match in Go files")
|
||
|
|
}
|
||
|
|
|
||
|
|
options.Pattern = fs.Arg(0)
|
||
|
|
if fs.NArg() > 1 {
|
||
|
|
options.Path = fs.Arg(1)
|
||
|
|
} else {
|
||
|
|
options.Path = "."
|
||
|
|
}
|
||
|
|
|
||
|
|
results, err := searchFiles(options)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("search error: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
if len(results) == 0 {
|
||
|
|
fmt.Printf("%sNo matches found for '%s'%s\n", colors.Gray, options.Pattern, colors.Reset)
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
displaySearchResults(results, options)
|
||
|
|
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func searchFiles(opts SearchOptions) ([]SearchResult, error) {
|
||
|
|
var results []SearchResult
|
||
|
|
|
||
|
|
pattern := opts.Pattern
|
||
|
|
if opts.CaseInsensitive {
|
||
|
|
pattern = strings.ToLower(pattern)
|
||
|
|
}
|
||
|
|
|
||
|
|
var regex *regexp.Regexp
|
||
|
|
if opts.WholeWord {
|
||
|
|
regex = regexp.MustCompile(`\b` + regexp.QuoteMeta(pattern) + `\b`)
|
||
|
|
} else {
|
||
|
|
regex = regexp.MustCompile(regexp.QuoteMeta(pattern))
|
||
|
|
}
|
||
|
|
|
||
|
|
err := filepath.Walk(opts.Path, func(path string, info os.FileInfo, err error) error {
|
||
|
|
if err != nil {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
if info.IsDir() {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
baseName := filepath.Base(path)
|
||
|
|
matched, err := filepath.Match(opts.FilePattern, baseName)
|
||
|
|
if err != nil || !matched {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
fileResults := searchFile(path, regex, opts)
|
||
|
|
results = append(results, fileResults...)
|
||
|
|
|
||
|
|
return nil
|
||
|
|
})
|
||
|
|
|
||
|
|
return results, err
|
||
|
|
}
|
||
|
|
|
||
|
|
func searchFile(path string, regex *regexp.Regexp, opts SearchOptions) []SearchResult {
|
||
|
|
var results []SearchResult
|
||
|
|
|
||
|
|
file, err := os.Open(path)
|
||
|
|
if err != nil {
|
||
|
|
return results
|
||
|
|
}
|
||
|
|
defer file.Close()
|
||
|
|
|
||
|
|
scanner := bufio.NewScanner(file)
|
||
|
|
lineNum := 0
|
||
|
|
|
||
|
|
for scanner.Scan() {
|
||
|
|
lineNum++
|
||
|
|
line := scanner.Text()
|
||
|
|
check := line
|
||
|
|
|
||
|
|
if opts.CaseInsensitive {
|
||
|
|
check = strings.ToLower(check)
|
||
|
|
}
|
||
|
|
|
||
|
|
if regex.MatchString(check) {
|
||
|
|
results = append(results, SearchResult{
|
||
|
|
File: path,
|
||
|
|
Line: lineNum,
|
||
|
|
Content: line,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return results
|
||
|
|
}
|
||
|
|
|
||
|
|
func displaySearchResults(results []SearchResult, opts SearchOptions) {
|
||
|
|
files := make(map[string][]SearchResult)
|
||
|
|
for _, r := range results {
|
||
|
|
files[r.File] = append(files[r.File], r)
|
||
|
|
}
|
||
|
|
|
||
|
|
PrintBoxHeader(fmt.Sprintf("SEARCH RESULTS (%d matches in %d files)", len(results), len(files)), colors.Cyan)
|
||
|
|
|
||
|
|
for file, fileResults := range files {
|
||
|
|
relPath := file
|
||
|
|
if strings.Contains(relPath, "/") {
|
||
|
|
parts := strings.Split(relPath, "/")
|
||
|
|
if len(parts) > 3 {
|
||
|
|
relPath = ".../" + strings.Join(parts[len(parts)-2:], "/")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fmt.Printf("\n%s%s%s:%s\n", colors.Bold, colors.Yellow, relPath, colors.Reset)
|
||
|
|
|
||
|
|
for _, r := range fileResults {
|
||
|
|
content := r.Content
|
||
|
|
if len(content) > 100 {
|
||
|
|
content = content[:97] + "..."
|
||
|
|
}
|
||
|
|
|
||
|
|
if opts.LineNumbers {
|
||
|
|
fmt.Printf("%s%4d:%s %s\n", colors.Gray, r.Line, colors.Reset, content)
|
||
|
|
} else {
|
||
|
|
fmt.Printf(" %s\n", content)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fmt.Println()
|
||
|
|
}
|