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