fileutil.go

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