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}