ls.go

  1package fsext
  2
  3import (
  4	"errors"
  5	"log/slog"
  6	"os"
  7	"path/filepath"
  8	"slices"
  9	"strings"
 10	"sync"
 11
 12	"github.com/charlievieth/fastwalk"
 13	"github.com/charmbracelet/crush/internal/csync"
 14	"github.com/charmbracelet/crush/internal/home"
 15	ignore "github.com/sabhiram/go-gitignore"
 16)
 17
 18// commonIgnorePatterns contains commonly ignored files and directories
 19var commonIgnorePatterns = sync.OnceValue(func() ignore.IgnoreParser {
 20	return ignore.CompileIgnoreLines(
 21		// Version control
 22		".git",
 23		".svn",
 24		".hg",
 25		".bzr",
 26
 27		// IDE and editor files
 28		".vscode",
 29		".idea",
 30		"*.swp",
 31		"*.swo",
 32		"*~",
 33		".DS_Store",
 34		"Thumbs.db",
 35
 36		// Build artifacts and dependencies
 37		"node_modules",
 38		"target",
 39		"build",
 40		"dist",
 41		"out",
 42		"bin",
 43		"obj",
 44		"*.o",
 45		"*.so",
 46		"*.dylib",
 47		"*.dll",
 48		"*.exe",
 49
 50		// Logs and temporary files
 51		"*.log",
 52		"*.tmp",
 53		"*.temp",
 54		".cache",
 55		".tmp",
 56
 57		// Language-specific
 58		"__pycache__",
 59		"*.pyc",
 60		"*.pyo",
 61		".pytest_cache",
 62		"vendor",
 63		"Cargo.lock",
 64		"package-lock.json",
 65		"yarn.lock",
 66		"pnpm-lock.yaml",
 67
 68		// OS generated files
 69		".Trash",
 70		".Spotlight-V100",
 71		".fseventsd",
 72
 73		// Crush
 74		".crush",
 75
 76		// macOS stuff
 77		"OrbStack",
 78		".local",
 79		".share",
 80	)
 81})
 82
 83var homeIgnore = sync.OnceValue(func() ignore.IgnoreParser {
 84	home := home.Dir()
 85	var lines []string
 86	for _, name := range []string{
 87		filepath.Join(home, ".gitignore"),
 88		filepath.Join(home, ".config", "git", "ignore"),
 89		filepath.Join(home, ".config", "crush", "ignore"),
 90	} {
 91		if bts, err := os.ReadFile(name); err == nil {
 92			lines = append(lines, strings.Split(string(bts), "\n")...)
 93		}
 94	}
 95	return ignore.CompileIgnoreLines(lines...)
 96})
 97
 98type directoryLister struct {
 99	ignores  *csync.Map[string, ignore.IgnoreParser]
100	rootPath string
101}
102
103func NewDirectoryLister(rootPath string) *directoryLister {
104	dl := &directoryLister{
105		rootPath: rootPath,
106		ignores:  csync.NewMap[string, ignore.IgnoreParser](),
107	}
108	dl.getIgnore(rootPath)
109	return dl
110}
111
112// git checks, in order:
113// - ./.gitignore, ../.gitignore, etc, until repo root
114// ~/.config/git/ignore
115// ~/.gitignore
116//
117// This will do the following:
118// - the given ignorePatterns
119// - [commonIgnorePatterns]
120// - ./.gitignore, ../.gitignore, etc, until dl.rootPath
121// - ./.crushignore, ../.crushignore, etc, until dl.rootPath
122// ~/.config/git/ignore
123// ~/.gitignore
124// ~/.config/crush/ignore
125func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bool {
126	if len(ignorePatterns) > 0 {
127		base := filepath.Base(path)
128		for _, pattern := range ignorePatterns {
129			if matched, err := filepath.Match(pattern, base); err == nil && matched {
130				return true
131			}
132		}
133	}
134
135	// Don't apply gitignore rules to the root directory itself
136	// In gitignore semantics, patterns don't apply to the repo root
137	if path == dl.rootPath {
138		return false
139	}
140
141	relPath, err := filepath.Rel(dl.rootPath, path)
142	if err != nil {
143		relPath = path
144	}
145
146	if commonIgnorePatterns().MatchesPath(relPath) {
147		slog.Debug("ignoring common pattern", "path", relPath)
148		return true
149	}
150
151	parentDir := filepath.Dir(path)
152	ignoreParser := dl.getIgnore(parentDir)
153	if ignoreParser.MatchesPath(relPath) {
154		slog.Debug("ignoring dir pattern", "path", relPath, "dir", parentDir)
155		return true
156	}
157
158	// For directories, also check with trailing slash (gitignore convention)
159	if ignoreParser.MatchesPath(relPath + "/") {
160		slog.Debug("ignoring dir pattern with slash", "path", relPath+"/", "dir", parentDir)
161		return true
162	}
163
164	if dl.checkParentIgnores(relPath) {
165		return true
166	}
167
168	if homeIgnore().MatchesPath(relPath) {
169		slog.Debug("ignoring home dir pattern", "path", relPath)
170		return true
171	}
172
173	return false
174}
175
176func (dl *directoryLister) checkParentIgnores(path string) bool {
177	parent := filepath.Dir(filepath.Dir(path))
178	for parent != "." && path != "." {
179		if dl.getIgnore(parent).MatchesPath(path) {
180			slog.Debug("ingoring parent dir pattern", "path", path, "dir", parent)
181			return true
182		}
183		if parent == dl.rootPath {
184			break
185		}
186		parent = filepath.Dir(parent)
187	}
188	return false
189}
190
191func (dl *directoryLister) getIgnore(path string) ignore.IgnoreParser {
192	return dl.ignores.GetOrSet(path, func() ignore.IgnoreParser {
193		var lines []string
194		for _, ign := range []string{".crushignore", ".gitignore"} {
195			name := filepath.Join(path, ign)
196			if content, err := os.ReadFile(name); err == nil {
197				lines = append(lines, strings.Split(string(content), "\n")...)
198			}
199		}
200		if len(lines) == 0 {
201			// Return a no-op parser to avoid nil checks
202			return ignore.CompileIgnoreLines()
203		}
204		return ignore.CompileIgnoreLines(lines...)
205	})
206}
207
208type (
209	DirectoryLister         func(initialPath string, ignorePatterns []string) ([]string, bool, error)
210	DirectoryListerResolver func() DirectoryLister
211)
212
213func ResolveDirectoryLister(maxDepth, limit int) DirectoryListerResolver {
214	return func() DirectoryLister {
215		return listDirectory(maxDepth, limit)
216	}
217}
218
219func listDirectory(maxDepth, limit int) func(initialPath string, ignorePatterns []string) ([]string, bool, error) {
220	return func(initialPath string, ignorePatterns []string) ([]string, bool, error) {
221		return ListDirectory(initialPath, ignorePatterns, maxDepth, limit)
222	}
223}
224
225// ListDirectory lists files and directories in the specified path,
226func ListDirectory(initialPath string, ignorePatterns []string, depth, limit int) ([]string, bool, error) {
227	found := csync.NewSlice[string]()
228	dl := NewDirectoryLister(initialPath)
229
230	slog.Warn("listing directory", "path", initialPath, "depth", depth, "limit", limit, "ignorePatterns", ignorePatterns)
231
232	conf := fastwalk.Config{
233		Follow:   true,
234		ToSlash:  fastwalk.DefaultToSlash(),
235		Sort:     fastwalk.SortDirsFirst,
236		MaxDepth: depth,
237	}
238
239	err := fastwalk.Walk(&conf, initialPath, func(path string, d os.DirEntry, err error) error {
240		if err != nil {
241			return nil // Skip files we don't have permission to access
242		}
243
244		if dl.shouldIgnore(path, ignorePatterns) {
245			if d.IsDir() {
246				return filepath.SkipDir
247			}
248			return nil
249		}
250
251		if path != initialPath {
252			if d.IsDir() {
253				path = path + string(filepath.Separator)
254			}
255			found.Append(path)
256		}
257
258		if limit > 0 && found.Len() >= limit {
259			return filepath.SkipAll
260		}
261
262		return nil
263	})
264	if err != nil && !errors.Is(err, filepath.SkipAll) {
265		return nil, false, err
266	}
267
268	matches, truncated := truncate(slices.Collect(found.Seq()), limit)
269	return matches, truncated || errors.Is(err, filepath.SkipAll), nil
270}