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