ls.go

  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}