1package fsext
2
3import (
4 "errors"
5 "log/slog"
6 "os"
7 "path/filepath"
8 "slices"
9 "strings"
10 "sync"
11
12 "github.com/charlievieth/fastwalk"
13 "github.com/charmbracelet/crush/internal/csync"
14 "github.com/charmbracelet/crush/internal/home"
15 ignore "github.com/sabhiram/go-gitignore"
16)
17
18// commonIgnorePatterns contains commonly ignored files and directories
19var commonIgnorePatterns = sync.OnceValue(func() ignore.IgnoreParser {
20 return ignore.CompileIgnoreLines(
21 // Version control
22 ".git",
23 ".svn",
24 ".hg",
25 ".bzr",
26
27 // IDE and editor files
28 ".vscode",
29 ".idea",
30 "*.swp",
31 "*.swo",
32 "*~",
33 ".DS_Store",
34 "Thumbs.db",
35
36 // Build artifacts and dependencies
37 "node_modules",
38 "target",
39 "build",
40 "dist",
41 "out",
42 "bin",
43 "obj",
44 "*.o",
45 "*.so",
46 "*.dylib",
47 "*.dll",
48 "*.exe",
49
50 // Logs and temporary files
51 "*.log",
52 "*.tmp",
53 "*.temp",
54 ".cache",
55 ".tmp",
56
57 // Language-specific
58 "__pycache__",
59 "*.pyc",
60 "*.pyo",
61 ".pytest_cache",
62 "vendor",
63 "Cargo.lock",
64 "package-lock.json",
65 "yarn.lock",
66 "pnpm-lock.yaml",
67
68 // OS generated files
69 ".Trash",
70 ".Spotlight-V100",
71 ".fseventsd",
72
73 // Crush
74 ".crush",
75
76 // macOS stuff
77 "OrbStack",
78 ".local",
79 ".share",
80 )
81})
82
83var homeIgnore = sync.OnceValue(func() ignore.IgnoreParser {
84 home := home.Dir()
85 var lines []string
86 for _, name := range []string{
87 filepath.Join(home, ".gitignore"),
88 filepath.Join(home, ".config", "git", "ignore"),
89 filepath.Join(home, ".config", "crush", "ignore"),
90 } {
91 if bts, err := os.ReadFile(name); err == nil {
92 lines = append(lines, strings.Split(string(bts), "\n")...)
93 }
94 }
95 return ignore.CompileIgnoreLines(lines...)
96})
97
98type directoryLister struct {
99 ignores *csync.Map[string, ignore.IgnoreParser]
100 rootPath string
101}
102
103func NewDirectoryLister(rootPath string) *directoryLister {
104 dl := &directoryLister{
105 rootPath: rootPath,
106 ignores: csync.NewMap[string, ignore.IgnoreParser](),
107 }
108 dl.getIgnore(rootPath)
109 return dl
110}
111
112// git checks, in order:
113// - ./.gitignore, ../.gitignore, etc, until repo root
114// ~/.config/git/ignore
115// ~/.gitignore
116//
117// This will do the following:
118// - the given ignorePatterns
119// - [commonIgnorePatterns]
120// - ./.gitignore, ../.gitignore, etc, until dl.rootPath
121// - ./.crushignore, ../.crushignore, etc, until dl.rootPath
122// ~/.config/git/ignore
123// ~/.gitignore
124// ~/.config/crush/ignore
125func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bool {
126 if len(ignorePatterns) > 0 {
127 base := filepath.Base(path)
128 for _, pattern := range ignorePatterns {
129 if matched, err := filepath.Match(pattern, base); err == nil && matched {
130 return true
131 }
132 }
133 }
134
135 // Don't apply gitignore rules to the root directory itself
136 // In gitignore semantics, patterns don't apply to the repo root
137 if path == dl.rootPath {
138 return false
139 }
140
141 relPath, err := filepath.Rel(dl.rootPath, path)
142 if err != nil {
143 relPath = path
144 }
145
146 if commonIgnorePatterns().MatchesPath(relPath) {
147 slog.Debug("ignoring common pattern", "path", relPath)
148 return true
149 }
150
151 parentDir := filepath.Dir(path)
152 ignoreParser := dl.getIgnore(parentDir)
153 if ignoreParser.MatchesPath(relPath) {
154 slog.Debug("ignoring dir pattern", "path", relPath, "dir", parentDir)
155 return true
156 }
157
158 // For directories, also check with trailing slash (gitignore convention)
159 if ignoreParser.MatchesPath(relPath + "/") {
160 slog.Debug("ignoring dir pattern with slash", "path", relPath+"/", "dir", parentDir)
161 return true
162 }
163
164 if dl.checkParentIgnores(relPath) {
165 return true
166 }
167
168 if homeIgnore().MatchesPath(relPath) {
169 slog.Debug("ignoring home dir pattern", "path", relPath)
170 return true
171 }
172
173 return false
174}
175
176func (dl *directoryLister) checkParentIgnores(path string) bool {
177 parent := filepath.Dir(filepath.Dir(path))
178 for parent != "." && path != "." {
179 if dl.getIgnore(parent).MatchesPath(path) {
180 slog.Debug("ingoring parent dir pattern", "path", path, "dir", parent)
181 return true
182 }
183 if parent == dl.rootPath {
184 break
185 }
186 parent = filepath.Dir(parent)
187 }
188 return false
189}
190
191func (dl *directoryLister) getIgnore(path string) ignore.IgnoreParser {
192 return dl.ignores.GetOrSet(path, func() ignore.IgnoreParser {
193 var lines []string
194 for _, ign := range []string{".crushignore", ".gitignore"} {
195 name := filepath.Join(path, ign)
196 if content, err := os.ReadFile(name); err == nil {
197 lines = append(lines, strings.Split(string(content), "\n")...)
198 }
199 }
200 if len(lines) == 0 {
201 // Return a no-op parser to avoid nil checks
202 return ignore.CompileIgnoreLines()
203 }
204 return ignore.CompileIgnoreLines(lines...)
205 })
206}
207
208type (
209 DirectoryLister func(initialPath string, ignorePatterns []string) ([]string, bool, error)
210 DirectoryListerResolver func() DirectoryLister
211)
212
213func ResolveDirectoryLister(maxDepth, limit int) DirectoryListerResolver {
214 return func() DirectoryLister {
215 return listDirectory(maxDepth, limit)
216 }
217}
218
219func listDirectory(maxDepth, limit int) func(initialPath string, ignorePatterns []string) ([]string, bool, error) {
220 return func(initialPath string, ignorePatterns []string) ([]string, bool, error) {
221 return ListDirectory(initialPath, ignorePatterns, maxDepth, limit)
222 }
223}
224
225// ListDirectory lists files and directories in the specified path,
226func ListDirectory(initialPath string, ignorePatterns []string, depth, limit int) ([]string, bool, error) {
227 found := csync.NewSlice[string]()
228 dl := NewDirectoryLister(initialPath)
229
230 slog.Debug("listing directory", "path", initialPath, "depth", depth, "limit", limit, "ignorePatterns", ignorePatterns)
231
232 conf := fastwalk.Config{
233 Follow: true,
234 ToSlash: fastwalk.DefaultToSlash(),
235 Sort: fastwalk.SortDirsFirst,
236 MaxDepth: depth,
237 }
238
239 err := fastwalk.Walk(&conf, initialPath, func(path string, d os.DirEntry, err error) error {
240 if err != nil {
241 return nil // Skip files we don't have permission to access
242 }
243
244 if dl.shouldIgnore(path, ignorePatterns) {
245 if d.IsDir() {
246 return filepath.SkipDir
247 }
248 return nil
249 }
250
251 if path != initialPath {
252 if d.IsDir() {
253 path = path + string(filepath.Separator)
254 }
255 found.Append(path)
256 }
257
258 if limit > 0 && found.Len() >= limit {
259 return filepath.SkipAll
260 }
261
262 return nil
263 })
264 if err != nil && !errors.Is(err, filepath.SkipAll) {
265 return nil, false, err
266 }
267
268 matches, truncated := truncate(slices.Collect(found.Seq()), limit)
269 return matches, truncated || errors.Is(err, filepath.SkipAll), nil
270}