fileutil.go

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