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