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	// Don't apply gitignore rules to the root directory itself
129	// In gitignore semantics, patterns don't apply to the repo root
130	if path == dl.rootPath {
131		return false
132	}
133
134	relPath, err := filepath.Rel(dl.rootPath, path)
135	if err != nil {
136		relPath = path
137	}
138
139	if commonIgnorePatterns().MatchesPath(relPath) {
140		slog.Debug("ignoring common pattern", "path", relPath)
141		return true
142	}
143
144	if dl.getIgnore(filepath.Dir(path)).MatchesPath(relPath) {
145		slog.Debug("ignoring dir pattern", "path", relPath, "dir", filepath.Dir(path))
146		return true
147	}
148
149	if dl.checkParentIgnores(relPath) {
150		return true
151	}
152
153	if homeIgnore().MatchesPath(relPath) {
154		slog.Debug("ignoring home dir pattern", "path", relPath)
155		return true
156	}
157
158	return false
159}
160
161func (dl *directoryLister) checkParentIgnores(path string) bool {
162	parent := filepath.Dir(filepath.Dir(path))
163	for parent != dl.rootPath && parent != "." && path != "." {
164		if dl.getIgnore(parent).MatchesPath(path) {
165			slog.Debug("ingoring parent dir pattern", "path", path, "dir", parent)
166			return true
167		}
168		parent = filepath.Dir(parent)
169	}
170	return false
171}
172
173func (dl *directoryLister) getIgnore(path string) ignore.IgnoreParser {
174	return dl.ignores.GetOrSet(path, func() ignore.IgnoreParser {
175		var lines []string
176		for _, ign := range []string{".crushignore", ".gitignore"} {
177			name := filepath.Join(path, ign)
178			if content, err := os.ReadFile(name); err == nil {
179				lines = append(lines, strings.Split(string(content), "\n")...)
180			}
181		}
182		if len(lines) == 0 {
183			// Return a no-op parser to avoid nil checks
184			return ignore.CompileIgnoreLines()
185		}
186		return ignore.CompileIgnoreLines(lines...)
187	})
188}
189
190// ListDirectory lists files and directories in the specified path,
191func ListDirectory(initialPath string, ignorePatterns []string, limit int) ([]string, bool, error) {
192	var results []string
193	truncated := false
194	dl := NewDirectoryLister(initialPath)
195
196	conf := fastwalk.Config{
197		Follow: true,
198		// Use forward slashes when running a Windows binary under WSL or MSYS
199		ToSlash: fastwalk.DefaultToSlash(),
200		Sort:    fastwalk.SortDirsFirst,
201	}
202
203	err := fastwalk.Walk(&conf, initialPath, func(path string, d os.DirEntry, err error) error {
204		if err != nil {
205			return nil // Skip files we don't have permission to access
206		}
207
208		if dl.shouldIgnore(path, ignorePatterns) {
209			if d.IsDir() {
210				return filepath.SkipDir
211			}
212			return nil
213		}
214
215		if path != initialPath {
216			if d.IsDir() {
217				path = path + string(filepath.Separator)
218			}
219			results = append(results, path)
220		}
221
222		if limit > 0 && len(results) >= limit {
223			truncated = true
224			return filepath.SkipAll
225		}
226
227		return nil
228	})
229	if err != nil && len(results) == 0 {
230		return nil, truncated, err
231	}
232
233	return results, truncated, nil
234}