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