1package fsext
  2
  3import (
  4	"log/slog"
  5	"os"
  6	"path/filepath"
  7	"strings"
  8
  9	"github.com/charlievieth/fastwalk"
 10	"github.com/charmbracelet/crush/internal/config"
 11	"github.com/charmbracelet/crush/internal/csync"
 12	ignore "github.com/sabhiram/go-gitignore"
 13)
 14
 15// commonIgnorePatterns contains commonly ignored files and directories
 16var commonIgnorePatterns = ignore.CompileIgnoreLines(
 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  *csync.Map[string, ignore.IgnoreParser]
 75	rootPath string
 76}
 77
 78func NewDirectoryLister(rootPath string) *directoryLister {
 79	dl := &directoryLister{
 80		rootPath: rootPath,
 81		ignores:  csync.NewMap[string, ignore.IgnoreParser](),
 82	}
 83	dl.getIgnore(rootPath)
 84	dl.ignores.GetOrSet("~", func() ignore.IgnoreParser {
 85		home := config.HomeDir()
 86		var lines []string
 87		for _, name := range []string{
 88			filepath.Join(home, ".gitignore"),
 89			filepath.Join(home, ".config", "git", "ignore"),
 90			filepath.Join(home, ".config", "crush", "ignore"),
 91		} {
 92			if bts, err := os.ReadFile(name); err == nil {
 93				lines = append(lines, strings.Split(string(bts), "\n")...)
 94			}
 95		}
 96		return ignore.CompileIgnoreLines(lines...)
 97	})
 98	return dl
 99}
100
101// git checks, in order:
102// - ./.gitignore, ../.gitignore, etc, until repo root
103// ~/.config/git/ignore
104// ~/.gitignore
105//
106// This will do the following:
107// - the given ignorePatterns
108// - [commonIgnorePatterns]
109// - ./.gitignore, ../.gitignore, etc, until dl.rootPath
110// - ./.crushignore, ../.crushignore, etc, until dl.rootPath
111// ~/.config/git/ignore
112// ~/.gitignore
113// ~/.config/crush/ignore
114func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bool {
115	if len(ignorePatterns) > 0 {
116		base := filepath.Base(path)
117		for _, pattern := range ignorePatterns {
118			if matched, err := filepath.Match(pattern, base); err == nil && matched {
119				return true
120			}
121		}
122	}
123
124	relPath, err := filepath.Rel(dl.rootPath, path)
125	if err != nil {
126		relPath = path
127	}
128
129	if commonIgnorePatterns.MatchesPath(relPath) {
130		slog.Debug("ingoring common pattern", "path", relPath)
131		return true
132	}
133
134	if dl.getIgnore(filepath.Dir(path)).MatchesPath(relPath) {
135		slog.Debug("ingoring dir pattern", "path", relPath, "dir", filepath.Dir(path))
136		return true
137	}
138
139	if dl.checkParentIgnores(relPath) {
140		return true
141	}
142
143	if dl.getIgnore("~").MatchesPath(relPath) {
144		slog.Debug("ingoring home dir pattern", "path", relPath)
145		return true
146	}
147
148	return false
149}
150
151func (dl *directoryLister) checkParentIgnores(path string) bool {
152	parent := filepath.Dir(filepath.Dir(path))
153	for parent != dl.rootPath && parent != "." && path != "." {
154		if dl.getIgnore(parent).MatchesPath(path) {
155			slog.Debug("ingoring parent dir pattern", "path", path, "dir", parent)
156			return true
157		}
158		parent = filepath.Dir(parent)
159	}
160	return false
161}
162
163func (dl *directoryLister) getIgnore(path string) ignore.IgnoreParser {
164	return dl.ignores.GetOrSet(path, func() ignore.IgnoreParser {
165		var lines []string
166		for _, ign := range []string{".crushignore", ".gitignore"} {
167			name := filepath.Join(path, ign)
168			if content, err := os.ReadFile(name); err == nil {
169				lines = append(lines, strings.Split(string(content), "\n")...)
170			}
171		}
172		if len(lines) == 0 {
173			// Return a no-op parser to avoid nil checks
174			return ignore.CompileIgnoreLines()
175		}
176		return ignore.CompileIgnoreLines(lines...)
177	})
178}
179
180// ListDirectory lists files and directories in the specified path,
181func ListDirectory(initialPath string, ignorePatterns []string, limit int) ([]string, bool, error) {
182	var results []string
183	truncated := false
184	dl := NewDirectoryLister(initialPath)
185
186	conf := fastwalk.Config{
187		Follow: true,
188		// Use forward slashes when running a Windows binary under WSL or MSYS
189		ToSlash: fastwalk.DefaultToSlash(),
190		Sort:    fastwalk.SortDirsFirst,
191	}
192
193	err := fastwalk.Walk(&conf, initialPath, func(path string, d os.DirEntry, err error) error {
194		if err != nil {
195			return nil // Skip files we don't have permission to access
196		}
197
198		if dl.shouldIgnore(path, ignorePatterns) {
199			if d.IsDir() {
200				return filepath.SkipDir
201			}
202			return nil
203		}
204
205		if path != initialPath {
206			if d.IsDir() {
207				path = path + string(filepath.Separator)
208			}
209			results = append(results, path)
210		}
211
212		if limit > 0 && len(results) >= limit {
213			truncated = true
214			return filepath.SkipAll
215		}
216
217		return nil
218	})
219	if err != nil && len(results) == 0 {
220		return nil, truncated, err
221	}
222
223	return results, truncated, nil
224}