ls.go

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