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}