1package fsext
2
3import (
4 "fmt"
5 "os"
6 "path/filepath"
7 "sort"
8 "strings"
9 "time"
10
11 "github.com/bmatcuk/doublestar/v4"
12 "github.com/charlievieth/fastwalk"
13
14 ignore "github.com/sabhiram/go-gitignore"
15)
16
17type FileInfo struct {
18 Path string
19 ModTime time.Time
20}
21
22func SkipHidden(path string) bool {
23 // Check for hidden files (starting with a dot)
24 base := filepath.Base(path)
25 if base != "." && strings.HasPrefix(base, ".") {
26 return true
27 }
28
29 commonIgnoredDirs := map[string]bool{
30 ".crush": true,
31 "node_modules": true,
32 "vendor": true,
33 "dist": true,
34 "build": true,
35 "target": true,
36 ".git": true,
37 ".idea": true,
38 ".vscode": true,
39 "__pycache__": true,
40 "bin": true,
41 "obj": true,
42 "out": true,
43 "coverage": true,
44 "logs": true,
45 "generated": true,
46 "bower_components": true,
47 "jspm_packages": true,
48 }
49
50 parts := strings.SplitSeq(path, string(os.PathSeparator))
51 for part := range parts {
52 if commonIgnoredDirs[part] {
53 return true
54 }
55 }
56 return false
57}
58
59// FastGlobWalker provides gitignore-aware file walking with fastwalk
60type FastGlobWalker struct {
61 gitignore *ignore.GitIgnore
62 crushignore *ignore.GitIgnore
63 rootPath string
64}
65
66func NewFastGlobWalker(searchPath string) *FastGlobWalker {
67 walker := &FastGlobWalker{
68 rootPath: searchPath,
69 }
70
71 // Load gitignore if it exists
72 gitignorePath := filepath.Join(searchPath, ".gitignore")
73 if _, err := os.Stat(gitignorePath); err == nil {
74 if gi, err := ignore.CompileIgnoreFile(gitignorePath); err == nil {
75 walker.gitignore = gi
76 }
77 }
78
79 // Load crushignore if it exists
80 crushignorePath := filepath.Join(searchPath, ".crushignore")
81 if _, err := os.Stat(crushignorePath); err == nil {
82 if ci, err := ignore.CompileIgnoreFile(crushignorePath); err == nil {
83 walker.crushignore = ci
84 }
85 }
86
87 return walker
88}
89
90// ShouldSkip checks if a path should be skipped based on gitignore, crushignore, and hidden file rules
91func (w *FastGlobWalker) ShouldSkip(path string) bool {
92 if SkipHidden(path) {
93 return true
94 }
95
96 relPath, err := filepath.Rel(w.rootPath, path)
97 if err != nil {
98 return false
99 }
100
101 if w.gitignore != nil {
102 if w.gitignore.MatchesPath(relPath) {
103 return true
104 }
105 }
106
107 if w.crushignore != nil {
108 if w.crushignore.MatchesPath(relPath) {
109 return true
110 }
111 }
112
113 return false
114}
115
116func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool, error) {
117 walker := NewFastGlobWalker(searchPath)
118 var matches []FileInfo
119 conf := fastwalk.Config{
120 Follow: true,
121 // Use forward slashes when running a Windows binary under WSL or MSYS
122 ToSlash: fastwalk.DefaultToSlash(),
123 Sort: fastwalk.SortFilesFirst,
124 }
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 if walker.ShouldSkip(path) {
132 return filepath.SkipDir
133 }
134 return nil
135 }
136
137 if walker.ShouldSkip(path) {
138 return nil
139 }
140
141 // Check if path matches the pattern
142 relPath, err := filepath.Rel(searchPath, path)
143 if err != nil {
144 relPath = path
145 }
146
147 matched, err := doublestar.Match(pattern, relPath)
148 if err != nil || !matched {
149 return nil
150 }
151
152 info, err := d.Info()
153 if err != nil {
154 return nil
155 }
156
157 matches = append(matches, FileInfo{Path: path, ModTime: info.ModTime()})
158 if limit > 0 && len(matches) >= limit*2 {
159 return filepath.SkipAll
160 }
161 return nil
162 })
163 if err != nil {
164 return nil, false, fmt.Errorf("fastwalk error: %w", err)
165 }
166
167 sort.Slice(matches, func(i, j int) bool {
168 return matches[i].ModTime.After(matches[j].ModTime)
169 })
170
171 truncated := false
172 if limit > 0 && len(matches) > limit {
173 matches = matches[:limit]
174 truncated = true
175 }
176
177 results := make([]string, len(matches))
178 for i, m := range matches {
179 results[i] = m.Path
180 }
181 return results, truncated, nil
182}
183
184func PrettyPath(path string) string {
185 // replace home directory with ~
186 homeDir, err := os.UserHomeDir()
187 if err == nil {
188 path = strings.ReplaceAll(path, homeDir, "~")
189 }
190 return path
191}
192
193func DirTrim(pwd string, lim int) string {
194 var (
195 out string
196 sep = string(filepath.Separator)
197 )
198 dirs := strings.Split(pwd, sep)
199 if lim > len(dirs)-1 || lim <= 0 {
200 return pwd
201 }
202 for i := len(dirs) - 1; i > 0; i-- {
203 out = sep + out
204 if i == len(dirs)-1 {
205 out = dirs[i]
206 } else if i >= len(dirs)-lim {
207 out = string(dirs[i][0]) + out
208 } else {
209 out = "..." + out
210 break
211 }
212 }
213 out = filepath.Join("~", out)
214 return out
215}
216
217// PathOrPrefix returns the prefix if the path starts with it, or falls back to
218// the path otherwise.
219func PathOrPrefix(path, prefix string) string {
220 if HasPrefix(path, prefix) {
221 return prefix
222 }
223 return path
224}
225
226// HasPrefix checks if the given path starts with the specified prefix.
227// Uses filepath.Rel to determine if path is within prefix.
228func HasPrefix(path, prefix string) bool {
229 rel, err := filepath.Rel(prefix, path)
230 if err != nil {
231 return false
232 }
233 // If path is within prefix, Rel will not return a path starting with ".."
234 return !strings.HasPrefix(rel, "..")
235}
236
237// ToUnixLineEndings converts Windows line endings (CRLF) to Unix line endings (LF).
238func ToUnixLineEndings(content string) (string, bool) {
239 if strings.Contains(content, "\r\n") {
240 return strings.ReplaceAll(content, "\r\n", "\n"), true
241 }
242 return content, false
243}
244
245// ToWindowsLineEndings converts Unix line endings (LF) to Windows line endings (CRLF).
246func ToWindowsLineEndings(content string) (string, bool) {
247 if !strings.Contains(content, "\r\n") {
248 return strings.ReplaceAll(content, "\n", "\r\n"), true
249 }
250 return content, false
251}