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/csync"
 12	"github.com/charmbracelet/crush/internal/home"
 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 := home.Dir()
 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	parentDir := filepath.Dir(path)
145	ignoreParser := dl.getIgnore(parentDir)
146	if ignoreParser.MatchesPath(relPath) {
147		slog.Debug("ignoring dir pattern", "path", relPath, "dir", parentDir)
148		return true
149	}
150
151	// For directories, also check with trailing slash (gitignore convention)
152	if ignoreParser.MatchesPath(relPath + "/") {
153		slog.Debug("ignoring dir pattern with slash", "path", relPath+"/", "dir", parentDir)
154		return true
155	}
156
157	if dl.checkParentIgnores(relPath) {
158		return true
159	}
160
161	if homeIgnore().MatchesPath(relPath) {
162		slog.Debug("ignoring home dir pattern", "path", relPath)
163		return true
164	}
165
166	return false
167}
168
169func (dl *directoryLister) checkParentIgnores(path string) bool {
170	parent := filepath.Dir(filepath.Dir(path))
171	for parent != "." && path != "." {
172		if dl.getIgnore(parent).MatchesPath(path) {
173			slog.Debug("ingoring parent dir pattern", "path", path, "dir", parent)
174			return true
175		}
176		if parent == dl.rootPath {
177			break
178		}
179		parent = filepath.Dir(parent)
180	}
181	return false
182}
183
184func (dl *directoryLister) getIgnore(path string) ignore.IgnoreParser {
185	return dl.ignores.GetOrSet(path, func() ignore.IgnoreParser {
186		var lines []string
187		for _, ign := range []string{".crushignore", ".gitignore"} {
188			name := filepath.Join(path, ign)
189			if content, err := os.ReadFile(name); err == nil {
190				lines = append(lines, strings.Split(string(content), "\n")...)
191			}
192		}
193		if len(lines) == 0 {
194			// Return a no-op parser to avoid nil checks
195			return ignore.CompileIgnoreLines()
196		}
197		return ignore.CompileIgnoreLines(lines...)
198	})
199}
200
201// ListDirectory lists files and directories in the specified path,
202func ListDirectory(initialPath string, ignorePatterns []string, limit int) ([]string, bool, error) {
203	var results []string
204	truncated := false
205	dl := NewDirectoryLister(initialPath)
206
207	conf := fastwalk.Config{
208		Follow: true,
209		// Use forward slashes when running a Windows binary under WSL or MSYS
210		ToSlash: fastwalk.DefaultToSlash(),
211		Sort:    fastwalk.SortDirsFirst,
212	}
213
214	err := fastwalk.Walk(&conf, initialPath, func(path string, d os.DirEntry, err error) error {
215		if err != nil {
216			return nil // Skip files we don't have permission to access
217		}
218
219		if dl.shouldIgnore(path, ignorePatterns) {
220			if d.IsDir() {
221				return filepath.SkipDir
222			}
223			return nil
224		}
225
226		if path != initialPath {
227			if d.IsDir() {
228				path = path + string(filepath.Separator)
229			}
230			results = append(results, path)
231		}
232
233		if limit > 0 && len(results) >= limit {
234			truncated = true
235			return filepath.SkipAll
236		}
237
238		return nil
239	})
240	if err != nil && len(results) == 0 {
241		return nil, truncated, err
242	}
243
244	return results, truncated, nil
245}