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