1package fsext
2
3import (
4 "bytes"
5 "io"
6 "log/slog"
7 "os"
8 "path/filepath"
9 "strings"
10
11 "github.com/charlievieth/fastwalk"
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 = ignore.CompileIgnoreLines(
18 // Version control
19 ".git",
20 ".svn",
21 ".hg",
22 ".bzr",
23
24 // IDE and editor files
25 ".vscode",
26 ".idea",
27 "*.swp",
28 "*.swo",
29 "*~",
30 ".DS_Store",
31 "Thumbs.db",
32
33 // Build artifacts and dependencies
34 "node_modules",
35 "target",
36 "build",
37 "dist",
38 "out",
39 "bin",
40 "obj",
41 "*.o",
42 "*.so",
43 "*.dylib",
44 "*.dll",
45 "*.exe",
46
47 // Logs and temporary files
48 "*.log",
49 "*.tmp",
50 "*.temp",
51 ".cache",
52 ".tmp",
53
54 // Language-specific
55 "__pycache__",
56 "*.pyc",
57 "*.pyo",
58 ".pytest_cache",
59 "vendor",
60 "Cargo.lock",
61 "package-lock.json",
62 "yarn.lock",
63 "pnpm-lock.yaml",
64
65 // OS generated files
66 ".Trash",
67 ".Spotlight-V100",
68 ".fseventsd",
69
70 // Crush
71 ".crush",
72)
73
74type directoryLister struct {
75 ignores *csync.Map[string, ignore.IgnoreParser]
76 rootPath string
77}
78
79func NewDirectoryLister(rootPath string) *directoryLister {
80 return &directoryLister{
81 rootPath: rootPath,
82 ignores: csync.NewMap[string, ignore.IgnoreParser](),
83 }
84}
85
86func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bool {
87 relPath, err := filepath.Rel(dl.rootPath, path)
88 if err != nil {
89 relPath = path
90 }
91
92 base := filepath.Base(path)
93 for _, pattern := range ignorePatterns {
94 matched, err := filepath.Match(pattern, base)
95 if err == nil && matched {
96 slog.Info("ignoring path", "path", path)
97 return true
98 }
99 }
100
101 if commonIgnorePatterns.MatchesPath(relPath) || dl.getIgnore(path).MatchesPath(relPath) {
102 slog.Info("ignoring path", "path", path)
103 return true
104 }
105
106 parent := filepath.Dir(path)
107 for {
108 if dl.getIgnore(parent).MatchesPath(path) {
109 slog.Info("ignoring path", "path", path, "parent", parent)
110 return true
111 }
112 if parent == "/" || parent == "." { // TODO: windows
113 return false
114 }
115 parent = filepath.Dir(parent)
116 }
117}
118
119func (dl *directoryLister) getIgnore(path string) ignore.IgnoreParser {
120 return dl.ignores.GetOrSet(path, func() ignore.IgnoreParser {
121 var b bytes.Buffer
122 for _, ign := range []string{".crushignore", ".gitignore"} {
123 p := filepath.Join(path, ign)
124 if _, err := os.Stat(p); err == nil {
125 slog.Info("loading ignore file", "path", p)
126 f, err := os.Open(p)
127 if err != nil {
128 _ = f.Close()
129 slog.Error("Failed to open ignore file", "path", p, "error", err)
130 continue
131 }
132 if _, err := io.Copy(&b, f); err != nil {
133 slog.Error("Failed to read ignore file", "path", p, "error", err)
134 }
135 _ = f.Close()
136 }
137 }
138 return ignore.CompileIgnoreLines(strings.Split(b.String(), "\n")...)
139 })
140}
141
142// ListDirectory lists files and directories in the specified path,
143func ListDirectory(initialPath string, ignorePatterns []string, limit int) ([]string, bool, error) {
144 var results []string
145 truncated := false
146 dl := NewDirectoryLister(initialPath)
147
148 conf := fastwalk.Config{
149 Follow: true,
150 // Use forward slashes when running a Windows binary under WSL or MSYS
151 ToSlash: fastwalk.DefaultToSlash(),
152 Sort: fastwalk.SortDirsFirst,
153 }
154
155 err := fastwalk.Walk(&conf, initialPath, func(path string, d os.DirEntry, err error) error {
156 if err != nil {
157 return nil // Skip files we don't have permission to access
158 }
159
160 if dl.shouldIgnore(path, ignorePatterns) {
161 if d.IsDir() {
162 return filepath.SkipDir
163 }
164 return nil
165 }
166
167 if path != initialPath {
168 if d.IsDir() {
169 path = path + string(filepath.Separator)
170 }
171 results = append(results, path)
172 }
173
174 if limit > 0 && len(results) >= limit {
175 truncated = true
176 return filepath.SkipAll
177 }
178
179 return nil
180 })
181 if err != nil && len(results) == 0 {
182 return nil, truncated, err
183 }
184
185 return results, truncated, nil
186}