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