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 walker := NewFastGlobWalker(searchPath)
79 var matches []FileInfo
80 conf := fastwalk.Config{
81 Follow: true,
82 // Use forward slashes when running a Windows binary under WSL or MSYS
83 ToSlash: fastwalk.DefaultToSlash(),
84 Sort: fastwalk.SortFilesFirst,
85 }
86 err := fastwalk.Walk(&conf, searchPath, func(path string, d os.DirEntry, err error) error {
87 if err != nil {
88 return nil // Skip files we can't access
89 }
90
91 if d.IsDir() {
92 if walker.ShouldSkip(path) {
93 return filepath.SkipDir
94 }
95 return nil
96 }
97
98 if walker.ShouldSkip(path) {
99 return nil
100 }
101
102 // Check if path matches the pattern
103 relPath, err := filepath.Rel(searchPath, path)
104 if err != nil {
105 relPath = path
106 }
107
108 matched, err := doublestar.Match(pattern, relPath)
109 if err != nil || !matched {
110 return nil
111 }
112
113 info, err := d.Info()
114 if err != nil {
115 return nil
116 }
117
118 matches = append(matches, FileInfo{Path: path, ModTime: info.ModTime()})
119 if limit > 0 && len(matches) >= limit*2 {
120 return filepath.SkipAll
121 }
122 return nil
123 })
124 if err != nil {
125 return nil, false, fmt.Errorf("fastwalk error: %w", err)
126 }
127
128 sort.Slice(matches, func(i, j int) bool {
129 return matches[i].ModTime.After(matches[j].ModTime)
130 })
131
132 truncated := false
133 if limit > 0 && len(matches) > limit {
134 matches = matches[:limit]
135 truncated = true
136 }
137
138 results := make([]string, len(matches))
139 for i, m := range matches {
140 results[i] = m.Path
141 }
142 return results, truncated, nil
143}
144
145// ShouldExcludeFile checks if a file should be excluded from processing
146// based on common patterns and ignore rules
147func ShouldExcludeFile(rootPath, filePath string) bool {
148 return NewDirectoryLister(rootPath).
149 shouldIgnore(filePath, nil)
150}
151
152// WalkDirectories walks a directory tree and calls the provided function for each directory,
153// respecting hierarchical .gitignore/.crushignore files like git does.
154func WalkDirectories(rootPath string, fn func(path string, d os.DirEntry, err error) error) error {
155 dl := NewDirectoryLister(rootPath)
156
157 conf := fastwalk.Config{
158 Follow: true,
159 ToSlash: fastwalk.DefaultToSlash(),
160 Sort: fastwalk.SortDirsFirst,
161 }
162
163 return fastwalk.Walk(&conf, rootPath, func(path string, d os.DirEntry, err error) error {
164 if err != nil {
165 return fn(path, d, err)
166 }
167
168 // Only process directories
169 if !d.IsDir() {
170 return nil
171 }
172
173 // Check if directory should be ignored
174 if dl.shouldIgnore(path, nil) {
175 return filepath.SkipDir
176 }
177
178 return fn(path, d, err)
179 })
180}
181
182func PrettyPath(path string) string {
183 return home.Short(path)
184}
185
186func DirTrim(pwd string, lim int) string {
187 var (
188 out string
189 sep = string(filepath.Separator)
190 )
191 dirs := strings.Split(pwd, sep)
192 if lim > len(dirs)-1 || lim <= 0 {
193 return pwd
194 }
195 for i := len(dirs) - 1; i > 0; i-- {
196 out = sep + out
197 if i == len(dirs)-1 {
198 out = dirs[i]
199 } else if i >= len(dirs)-lim {
200 out = string(dirs[i][0]) + out
201 } else {
202 out = "..." + out
203 break
204 }
205 }
206 out = filepath.Join("~", out)
207 return out
208}
209
210// PathOrPrefix returns the prefix if the path starts with it, or falls back to
211// the path otherwise.
212func PathOrPrefix(path, prefix string) string {
213 if HasPrefix(path, prefix) {
214 return prefix
215 }
216 return path
217}
218
219// HasPrefix checks if the given path starts with the specified prefix.
220// Uses filepath.Rel to determine if path is within prefix.
221func HasPrefix(path, prefix string) bool {
222 rel, err := filepath.Rel(prefix, path)
223 if err != nil {
224 return false
225 }
226 // If path is within prefix, Rel will not return a path starting with ".."
227 return !strings.HasPrefix(rel, "..")
228}
229
230// ToUnixLineEndings converts Windows line endings (CRLF) to Unix line endings (LF).
231func ToUnixLineEndings(content string) (string, bool) {
232 if strings.Contains(content, "\r\n") {
233 return strings.ReplaceAll(content, "\r\n", "\n"), true
234 }
235 return content, false
236}
237
238// ToWindowsLineEndings converts Unix line endings (LF) to Windows line endings (CRLF).
239func ToWindowsLineEndings(content string) (string, bool) {
240 if !strings.Contains(content, "\r\n") {
241 return strings.ReplaceAll(content, "\n", "\r\n"), true
242 }
243 return content, false
244}