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