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}