fileutil.go

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