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