fileutil.go

  1package fsext
  2
  3import (
  4	"fmt"
  5	"log/slog"
  6	"os"
  7	"os/exec"
  8	"path/filepath"
  9	"sort"
 10	"strings"
 11	"sync"
 12	"time"
 13
 14	"github.com/bmatcuk/doublestar/v4"
 15	"github.com/charlievieth/fastwalk"
 16
 17	ignore "github.com/sabhiram/go-gitignore"
 18)
 19
 20var (
 21	rgPath  string
 22	fzfPath string
 23)
 24
 25func init() {
 26	var err error
 27	fzfPath, err = exec.LookPath("fzf")
 28	if err != nil {
 29		slog.Warn("FZF not found in $PATH. Some features might be limited or slower.")
 30	}
 31}
 32
 33type FileInfo struct {
 34	Path    string
 35	ModTime time.Time
 36}
 37
 38func SkipHidden(path string) bool {
 39	// Check for hidden files (starting with a dot)
 40	base := filepath.Base(path)
 41	if base != "." && strings.HasPrefix(base, ".") {
 42		return true
 43	}
 44
 45	commonIgnoredDirs := map[string]bool{
 46		".crush":           true,
 47		"node_modules":     true,
 48		"vendor":           true,
 49		"dist":             true,
 50		"build":            true,
 51		"target":           true,
 52		".git":             true,
 53		".idea":            true,
 54		".vscode":          true,
 55		"__pycache__":      true,
 56		"bin":              true,
 57		"obj":              true,
 58		"out":              true,
 59		"coverage":         true,
 60		"tmp":              true,
 61		"temp":             true,
 62		"logs":             true,
 63		"generated":        true,
 64		"bower_components": true,
 65		"jspm_packages":    true,
 66	}
 67
 68	parts := strings.SplitSeq(path, string(os.PathSeparator))
 69	for part := range parts {
 70		if commonIgnoredDirs[part] {
 71			return true
 72		}
 73	}
 74	return false
 75}
 76
 77// FastGlobWalker provides gitignore-aware file walking with fastwalk
 78type FastGlobWalker struct {
 79	gitignore *ignore.GitIgnore
 80	rootPath  string
 81}
 82
 83func NewFastGlobWalker(searchPath string) *FastGlobWalker {
 84	walker := &FastGlobWalker{
 85		rootPath: searchPath,
 86	}
 87
 88	// Load gitignore if it exists
 89	gitignorePath := filepath.Join(searchPath, ".gitignore")
 90	if _, err := os.Stat(gitignorePath); err == nil {
 91		if gi, err := ignore.CompileIgnoreFile(gitignorePath); err == nil {
 92			walker.gitignore = gi
 93		}
 94	}
 95
 96	return walker
 97}
 98
 99func shouldSkip(path, rootPath string, gitignore *ignore.GitIgnore) bool {
100	if SkipHidden(path) {
101		return true
102	}
103
104	if gitignore != nil {
105		relPath, err := filepath.Rel(rootPath, path)
106		if err == nil && gitignore.MatchesPath(relPath) {
107			return true
108		}
109	}
110
111	return false
112}
113
114func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool, error) {
115	var mu sync.Mutex
116	walker := NewFastGlobWalker(searchPath)
117	var matches []FileInfo
118	conf := fastwalk.Config{
119		Follow: true,
120		// Use forward slashes when running a Windows binary under WSL or MSYS
121		ToSlash: fastwalk.DefaultToSlash(),
122		Sort:    fastwalk.SortFilesFirst,
123	}
124	rootPath, gitignore := walker.rootPath, walker.gitignore
125	err := fastwalk.Walk(&conf, searchPath, func(path string, d os.DirEntry, err error) error {
126		if err != nil {
127			return nil // Skip files we can't access
128		}
129
130		if d.IsDir() {
131			mu.Lock()
132			if shouldSkip(path, rootPath, gitignore) {
133				mu.Unlock()
134				return filepath.SkipDir
135			}
136			mu.Unlock()
137			return nil
138		}
139
140		mu.Lock()
141		if shouldSkip(path, rootPath, gitignore) {
142			mu.Unlock()
143			return nil
144		}
145		mu.Unlock()
146
147		// Check if path matches the pattern
148		relPath, err := filepath.Rel(searchPath, path)
149		if err != nil {
150			relPath = path
151		}
152
153		matched, err := doublestar.Match(pattern, relPath)
154		if err != nil || !matched {
155			return nil
156		}
157
158		info, err := d.Info()
159		if err != nil {
160			return nil
161		}
162
163		mu.Lock()
164		defer mu.Unlock()
165
166		matches = append(matches, FileInfo{Path: path, ModTime: info.ModTime()})
167		if limit > 0 && len(matches) >= limit*2 {
168			return filepath.SkipAll
169		}
170		return nil
171	})
172	if err != nil {
173		return nil, false, fmt.Errorf("fastwalk error: %w", err)
174	}
175
176	sort.Slice(matches, func(i, j int) bool {
177		return matches[i].ModTime.After(matches[j].ModTime)
178	})
179
180	truncated := false
181	if limit > 0 && len(matches) > limit {
182		matches = matches[:limit]
183		truncated = true
184	}
185
186	results := make([]string, len(matches))
187	for i, m := range matches {
188		results[i] = m.Path
189	}
190	return results, truncated, nil
191}
192
193func PrettyPath(path string) string {
194	// replace home directory with ~
195	homeDir, err := os.UserHomeDir()
196	if err == nil {
197		path = strings.ReplaceAll(path, homeDir, "~")
198	}
199	return path
200}
201
202func DirTrim(pwd string, lim int) string {
203	var (
204		out string
205		sep = string(filepath.Separator)
206	)
207	dirs := strings.Split(pwd, sep)
208	if lim > len(dirs)-1 || lim <= 0 {
209		return pwd
210	}
211	for i := len(dirs) - 1; i > 0; i-- {
212		out = sep + out
213		if i == len(dirs)-1 {
214			out = dirs[i]
215		} else if i >= len(dirs)-lim {
216			out = string(dirs[i][0]) + out
217		} else {
218			out = "..." + out
219			break
220		}
221	}
222	out = filepath.Join("~", out)
223	return out
224}