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