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