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