1package fsext
  2
  3import (
  4	"fmt"
  5	"os"
  6	"path/filepath"
  7	"sort"
  8	"strings"
  9	"time"
 10
 11	"github.com/bmatcuk/doublestar/v4"
 12	"github.com/charlievieth/fastwalk"
 13	"github.com/charmbracelet/crush/internal/home"
 14)
 15
 16type FileInfo struct {
 17	Path    string
 18	ModTime time.Time
 19}
 20
 21func SkipHidden(path string) bool {
 22	// Check for hidden files (starting with a dot)
 23	base := filepath.Base(path)
 24	if base != "." && strings.HasPrefix(base, ".") {
 25		return true
 26	}
 27
 28	commonIgnoredDirs := map[string]bool{
 29		".crush":           true,
 30		"node_modules":     true,
 31		"vendor":           true,
 32		"dist":             true,
 33		"build":            true,
 34		"target":           true,
 35		".git":             true,
 36		".idea":            true,
 37		".vscode":          true,
 38		"__pycache__":      true,
 39		"bin":              true,
 40		"obj":              true,
 41		"out":              true,
 42		"coverage":         true,
 43		"logs":             true,
 44		"generated":        true,
 45		"bower_components": true,
 46		"jspm_packages":    true,
 47	}
 48
 49	parts := strings.SplitSeq(path, string(os.PathSeparator))
 50	for part := range parts {
 51		if commonIgnoredDirs[part] {
 52			return true
 53		}
 54	}
 55	return false
 56}
 57
 58// FastGlobWalker provides gitignore-aware file walking with fastwalk
 59// It uses hierarchical ignore checking like git does, checking .gitignore/.crushignore
 60// files in each directory from the root to the target path.
 61type FastGlobWalker struct {
 62	directoryLister *directoryLister
 63}
 64
 65func NewFastGlobWalker(searchPath string) *FastGlobWalker {
 66	return &FastGlobWalker{
 67		directoryLister: NewDirectoryLister(searchPath),
 68	}
 69}
 70
 71// ShouldSkip checks if a path should be skipped based on hierarchical gitignore,
 72// crushignore, and hidden file rules
 73func (w *FastGlobWalker) ShouldSkip(path string) bool {
 74	return w.directoryLister.shouldIgnore(path, nil)
 75}
 76
 77func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool, error) {
 78	// Normalize pattern to forward slashes on Windows so their config can use
 79	// backslashes
 80	pattern = filepath.ToSlash(pattern)
 81
 82	walker := NewFastGlobWalker(searchPath)
 83	var matches []FileInfo
 84	conf := fastwalk.Config{
 85		Follow: true,
 86		// Use forward slashes when running a Windows binary under WSL or MSYS
 87		ToSlash: fastwalk.DefaultToSlash(),
 88		Sort:    fastwalk.SortFilesFirst,
 89	}
 90	err := fastwalk.Walk(&conf, searchPath, func(path string, d os.DirEntry, err error) error {
 91		if err != nil {
 92			return nil // Skip files we can't access
 93		}
 94
 95		if d.IsDir() {
 96			if walker.ShouldSkip(path) {
 97				return filepath.SkipDir
 98			}
 99		}
100
101		if walker.ShouldSkip(path) {
102			return nil
103		}
104
105		relPath, err := filepath.Rel(searchPath, path)
106		if err != nil {
107			relPath = path
108		}
109
110		// Normalize separators to forward slashes
111		relPath = filepath.ToSlash(relPath)
112
113		// Check if path matches the pattern
114		matched, err := doublestar.Match(pattern, relPath)
115		if err != nil || !matched {
116			return nil
117		}
118
119		info, err := d.Info()
120		if err != nil {
121			return nil
122		}
123
124		matches = append(matches, FileInfo{Path: path, ModTime: info.ModTime()})
125		if limit > 0 && len(matches) >= limit*2 {
126			return filepath.SkipAll
127		}
128		return nil
129	})
130	if err != nil {
131		return nil, false, fmt.Errorf("fastwalk error: %w", err)
132	}
133
134	sort.Slice(matches, func(i, j int) bool {
135		return matches[i].ModTime.After(matches[j].ModTime)
136	})
137
138	truncated := false
139	if limit > 0 && len(matches) > limit {
140		matches = matches[:limit]
141		truncated = true
142	}
143
144	results := make([]string, len(matches))
145	for i, m := range matches {
146		results[i] = m.Path
147	}
148	return results, truncated, nil
149}
150
151// ShouldExcludeFile checks if a file should be excluded from processing
152// based on common patterns and ignore rules
153func ShouldExcludeFile(rootPath, filePath string) bool {
154	return NewDirectoryLister(rootPath).
155		shouldIgnore(filePath, nil)
156}
157
158// WalkDirectories walks a directory tree and calls the provided function for each directory,
159// respecting hierarchical .gitignore/.crushignore files like git does.
160func WalkDirectories(rootPath string, fn func(path string, d os.DirEntry, err error) error) error {
161	dl := NewDirectoryLister(rootPath)
162
163	conf := fastwalk.Config{
164		Follow:  true,
165		ToSlash: fastwalk.DefaultToSlash(),
166		Sort:    fastwalk.SortDirsFirst,
167	}
168
169	return fastwalk.Walk(&conf, rootPath, func(path string, d os.DirEntry, err error) error {
170		if err != nil {
171			return fn(path, d, err)
172		}
173
174		// Only process directories
175		if !d.IsDir() {
176			return nil
177		}
178
179		// Check if directory should be ignored
180		if dl.shouldIgnore(path, nil) {
181			return filepath.SkipDir
182		}
183
184		return fn(path, d, err)
185	})
186}
187
188func PrettyPath(path string) string {
189	return home.Short(path)
190}
191
192func DirTrim(pwd string, lim int) string {
193	var (
194		out string
195		sep = string(filepath.Separator)
196	)
197	dirs := strings.Split(pwd, sep)
198	if lim > len(dirs)-1 || lim <= 0 {
199		return pwd
200	}
201	for i := len(dirs) - 1; i > 0; i-- {
202		out = sep + out
203		if i == len(dirs)-1 {
204			out = dirs[i]
205		} else if i >= len(dirs)-lim {
206			out = string(dirs[i][0]) + out
207		} else {
208			out = "..." + out
209			break
210		}
211	}
212	out = filepath.Join("~", out)
213	return out
214}
215
216// PathOrPrefix returns the prefix if the path starts with it, or falls back to
217// the path otherwise.
218func PathOrPrefix(path, prefix string) string {
219	if HasPrefix(path, prefix) {
220		return prefix
221	}
222	return path
223}
224
225// HasPrefix checks if the given path starts with the specified prefix.
226// Uses filepath.Rel to determine if path is within prefix.
227func HasPrefix(path, prefix string) bool {
228	rel, err := filepath.Rel(prefix, path)
229	if err != nil {
230		return false
231	}
232	// If path is within prefix, Rel will not return a path starting with ".."
233	return !strings.HasPrefix(rel, "..")
234}
235
236// ToUnixLineEndings converts Windows line endings (CRLF) to Unix line endings (LF).
237func ToUnixLineEndings(content string) (string, bool) {
238	if strings.Contains(content, "\r\n") {
239		return strings.ReplaceAll(content, "\r\n", "\n"), true
240	}
241	return content, false
242}
243
244// ToWindowsLineEndings converts Unix line endings (LF) to Windows line endings (CRLF).
245func ToWindowsLineEndings(content string) (string, bool) {
246	if !strings.Contains(content, "\r\n") {
247		return strings.ReplaceAll(content, "\n", "\r\n"), true
248	}
249	return content, false
250}