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