1package fsext
2
3import (
4 "errors"
5 "fmt"
6 "os"
7 "path/filepath"
8 "slices"
9 "strings"
10 "time"
11
12 "github.com/bmatcuk/doublestar/v4"
13 "github.com/charlievieth/fastwalk"
14 "github.com/charmbracelet/crush/internal/csync"
15 "github.com/charmbracelet/crush/internal/home"
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
61// It uses hierarchical ignore checking like git does, checking .gitignore/.crushignore
62// files in each directory from the root to the target path.
63type FastGlobWalker struct {
64 directoryLister *directoryLister
65}
66
67func NewFastGlobWalker(searchPath string) *FastGlobWalker {
68 return &FastGlobWalker{
69 directoryLister: NewDirectoryLister(searchPath),
70 }
71}
72
73// ShouldSkip checks if a file path should be skipped based on hierarchical gitignore,
74// crushignore, and hidden file rules.
75func (w *FastGlobWalker) ShouldSkip(path string) bool {
76 return w.directoryLister.shouldIgnore(path, nil, false)
77}
78
79// ShouldSkipDir checks if a directory path should be skipped based on hierarchical
80// gitignore, crushignore, and hidden file rules.
81func (w *FastGlobWalker) ShouldSkipDir(path string) bool {
82 return w.directoryLister.shouldIgnore(path, nil, true)
83}
84
85// Glob globs files.
86//
87// Does not respect gitignore.
88func Glob(pattern string, cwd string, limit int) ([]string, bool, error) {
89 return globWithDoubleStar(pattern, cwd, limit, false)
90}
91
92// GlobGitignoreAware globs files respecting gitignore.
93func GlobGitignoreAware(pattern string, cwd string, limit int) ([]string, bool, error) {
94 return globWithDoubleStar(pattern, cwd, limit, true)
95}
96
97func globWithDoubleStar(pattern, searchPath string, limit int, gitignore bool) ([]string, bool, error) {
98 // Normalize pattern to forward slashes on Windows so their config can use
99 // backslashes
100 pattern = filepath.ToSlash(pattern)
101
102 walker := NewFastGlobWalker(searchPath)
103 found := csync.NewSlice[FileInfo]()
104 conf := fastwalk.Config{
105 Follow: true,
106 ToSlash: fastwalk.DefaultToSlash(),
107 Sort: fastwalk.SortFilesFirst,
108 }
109 err := fastwalk.Walk(&conf, searchPath, func(path string, d os.DirEntry, err error) error {
110 if err != nil {
111 return nil // Skip files we can't access
112 }
113
114 isDir := d.IsDir()
115 if isDir {
116 if gitignore && walker.ShouldSkipDir(path) {
117 return filepath.SkipDir
118 }
119 } else {
120 if gitignore && walker.ShouldSkip(path) {
121 return nil
122 }
123 }
124
125 relPath, err := filepath.Rel(searchPath, path)
126 if err != nil {
127 relPath = path
128 }
129
130 // Normalize separators to forward slashes
131 relPath = filepath.ToSlash(relPath)
132
133 // Check if path matches the pattern
134 matched, err := doublestar.Match(pattern, relPath)
135 if err != nil || !matched {
136 return nil
137 }
138
139 info, err := d.Info()
140 if err != nil {
141 return nil
142 }
143
144 found.Append(FileInfo{Path: path, ModTime: info.ModTime()})
145 if limit > 0 && found.Len() >= limit*2 { // NOTE: why x2?
146 return filepath.SkipAll
147 }
148 return nil
149 })
150 if err != nil && !errors.Is(err, filepath.SkipAll) {
151 return nil, false, fmt.Errorf("fastwalk error: %w", err)
152 }
153
154 matches := slices.SortedFunc(found.Seq(), func(a, b FileInfo) int {
155 return b.ModTime.Compare(a.ModTime)
156 })
157 matches, truncated := truncate(matches, limit)
158
159 results := make([]string, len(matches))
160 for i, m := range matches {
161 results[i] = m.Path
162 }
163 return results, truncated || errors.Is(err, filepath.SkipAll), nil
164}
165
166// ShouldExcludeFile checks if a file should be excluded from processing
167// based on common patterns and ignore rules.
168func ShouldExcludeFile(rootPath, filePath string) bool {
169 info, err := os.Stat(filePath)
170 isDir := err == nil && info.IsDir()
171 return NewDirectoryLister(rootPath).
172 shouldIgnore(filePath, nil, isDir)
173}
174
175func PrettyPath(path string) string {
176 return home.Short(path)
177}
178
179func DirTrim(pwd string, lim int) string {
180 var (
181 out string
182 sep = string(filepath.Separator)
183 )
184 dirs := strings.Split(pwd, sep)
185 if lim > len(dirs)-1 || lim <= 0 {
186 return pwd
187 }
188 for i := len(dirs) - 1; i > 0; i-- {
189 out = sep + out
190 if i == len(dirs)-1 {
191 out = dirs[i]
192 } else if i >= len(dirs)-lim {
193 out = string(dirs[i][0]) + out
194 } else {
195 out = "..." + out
196 break
197 }
198 }
199 out = filepath.Join("~", out)
200 return out
201}
202
203// PathOrPrefix returns the prefix if the path starts with it, or falls back to
204// the path otherwise.
205func PathOrPrefix(path, prefix string) string {
206 if HasPrefix(path, prefix) {
207 return prefix
208 }
209 return path
210}
211
212// HasPrefix checks if the given path starts with the specified prefix.
213// Uses filepath.Rel to determine if path is within prefix.
214func HasPrefix(path, prefix string) bool {
215 rel, err := filepath.Rel(prefix, path)
216 if err != nil {
217 return false
218 }
219 // If path is within prefix, Rel will not return a path starting with ".."
220 return !strings.HasPrefix(rel, "..")
221}
222
223// ToUnixLineEndings converts Windows line endings (CRLF) to Unix line endings (LF).
224func ToUnixLineEndings(content string) (string, bool) {
225 if strings.Contains(content, "\r\n") {
226 return strings.ReplaceAll(content, "\r\n", "\n"), true
227 }
228 return content, false
229}
230
231// ToWindowsLineEndings converts Unix line endings (LF) to Windows line endings (CRLF).
232func ToWindowsLineEndings(content string) (string, bool) {
233 if !strings.Contains(content, "\r\n") {
234 return strings.ReplaceAll(content, "\n", "\r\n"), true
235 }
236 return content, false
237}
238
239func truncate[T any](input []T, limit int) ([]T, bool) {
240 if limit > 0 && len(input) > limit {
241 return input[:limit], true
242 }
243 return input, false
244}