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 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)
77}
78
79func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool, error) {
80 // Normalize pattern to forward slashes on Windows so their config can use
81 // backslashes
82 pattern = filepath.ToSlash(pattern)
83
84 walker := NewFastGlobWalker(searchPath)
85 found := csync.NewSlice[FileInfo]()
86 conf := fastwalk.Config{
87 Follow: true,
88 ToSlash: fastwalk.DefaultToSlash(),
89 Sort: fastwalk.SortFilesFirst,
90 }
91 err := fastwalk.Walk(&conf, searchPath, func(path string, d os.DirEntry, err error) error {
92 if err != nil {
93 return nil // Skip files we can't access
94 }
95
96 if d.IsDir() {
97 if walker.ShouldSkip(path) {
98 return filepath.SkipDir
99 }
100 }
101
102 if walker.ShouldSkip(path) {
103 return nil
104 }
105
106 relPath, err := filepath.Rel(searchPath, path)
107 if err != nil {
108 relPath = path
109 }
110
111 // Normalize separators to forward slashes
112 relPath = filepath.ToSlash(relPath)
113
114 // Check if path matches the pattern
115 matched, err := doublestar.Match(pattern, relPath)
116 if err != nil || !matched {
117 return nil
118 }
119
120 info, err := d.Info()
121 if err != nil {
122 return nil
123 }
124
125 found.Append(FileInfo{Path: path, ModTime: info.ModTime()})
126 if limit > 0 && found.Len() >= limit*2 { // NOTE: why x2?
127 return filepath.SkipAll
128 }
129 return nil
130 })
131 if err != nil && !errors.Is(err, filepath.SkipAll) {
132 return nil, false, fmt.Errorf("fastwalk error: %w", err)
133 }
134
135 matches := slices.SortedFunc(found.Seq(), func(a, b FileInfo) int {
136 return b.ModTime.Compare(a.ModTime)
137 })
138 matches, truncated := truncate(matches, limit)
139
140 results := make([]string, len(matches))
141 for i, m := range matches {
142 results[i] = m.Path
143 }
144 return results, truncated || errors.Is(err, filepath.SkipAll), nil
145}
146
147// ShouldExcludeFile checks if a file should be excluded from processing
148// based on common patterns and ignore rules
149func ShouldExcludeFile(rootPath, filePath string) bool {
150 return NewDirectoryLister(rootPath).
151 shouldIgnore(filePath, nil)
152}
153
154func PrettyPath(path string) string {
155 return home.Short(path)
156}
157
158func DirTrim(pwd string, lim int) string {
159 var (
160 out string
161 sep = string(filepath.Separator)
162 )
163 dirs := strings.Split(pwd, sep)
164 if lim > len(dirs)-1 || lim <= 0 {
165 return pwd
166 }
167 for i := len(dirs) - 1; i > 0; i-- {
168 out = sep + out
169 if i == len(dirs)-1 {
170 out = dirs[i]
171 } else if i >= len(dirs)-lim {
172 out = string(dirs[i][0]) + out
173 } else {
174 out = "..." + out
175 break
176 }
177 }
178 out = filepath.Join("~", out)
179 return out
180}
181
182// PathOrPrefix returns the prefix if the path starts with it, or falls back to
183// the path otherwise.
184func PathOrPrefix(path, prefix string) string {
185 if HasPrefix(path, prefix) {
186 return prefix
187 }
188 return path
189}
190
191// HasPrefix checks if the given path starts with the specified prefix.
192// Uses filepath.Rel to determine if path is within prefix.
193func HasPrefix(path, prefix string) bool {
194 rel, err := filepath.Rel(prefix, path)
195 if err != nil {
196 return false
197 }
198 // If path is within prefix, Rel will not return a path starting with ".."
199 return !strings.HasPrefix(rel, "..")
200}
201
202// ToUnixLineEndings converts Windows line endings (CRLF) to Unix line endings (LF).
203func ToUnixLineEndings(content string) (string, bool) {
204 if strings.Contains(content, "\r\n") {
205 return strings.ReplaceAll(content, "\r\n", "\n"), true
206 }
207 return content, false
208}
209
210// ToWindowsLineEndings converts Unix line endings (LF) to Windows line endings (CRLF).
211func ToWindowsLineEndings(content string) (string, bool) {
212 if !strings.Contains(content, "\r\n") {
213 return strings.ReplaceAll(content, "\n", "\r\n"), true
214 }
215 return content, false
216}
217
218func truncate[T any](input []T, limit int) ([]T, bool) {
219 if limit > 0 && len(input) > limit {
220 return input[:limit], true
221 }
222 return input, false
223}