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, depth, limit int) ([]string, bool, error)
210	DirectoryListerResolver func() DirectoryLister
211)
212
213func ResolveDirectoryLister() DirectoryLister {
214	return listDirectory
215}
216
217func listDirectory(initialPath string, ignorePatterns []string, depth, limit int) ([]string, bool, error) {
218	return ListDirectory(initialPath, ignorePatterns, depth, limit)
219}
220
221// ListDirectory lists files and directories in the specified path,
222func ListDirectory(initialPath string, ignorePatterns []string, depth, limit int) ([]string, bool, error) {
223	found := csync.NewSlice[string]()
224	dl := NewDirectoryLister(initialPath)
225
226	slog.Warn("listing directory", "path", initialPath, "depth", depth, "limit", limit, "ignorePatterns", ignorePatterns)
227
228	conf := fastwalk.Config{
229		Follow:   true,
230		ToSlash:  fastwalk.DefaultToSlash(),
231		Sort:     fastwalk.SortDirsFirst,
232		MaxDepth: depth,
233	}
234
235	err := fastwalk.Walk(&conf, initialPath, func(path string, d os.DirEntry, err error) error {
236		if err != nil {
237			return nil // Skip files we don't have permission to access
238		}
239
240		if dl.shouldIgnore(path, ignorePatterns) {
241			if d.IsDir() {
242				return filepath.SkipDir
243			}
244			return nil
245		}
246
247		if path != initialPath {
248			if d.IsDir() {
249				path = path + string(filepath.Separator)
250			}
251			found.Append(path)
252		}
253
254		if limit > 0 && found.Len() >= limit {
255			return filepath.SkipAll
256		}
257
258		return nil
259	})
260	if err != nil && !errors.Is(err, filepath.SkipAll) {
261		return nil, false, err
262	}
263
264	matches, truncated := truncate(slices.Collect(found.Seq()), limit)
265	return matches, truncated || errors.Is(err, filepath.SkipAll), nil
266}