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}