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}