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	walker := NewFastGlobWalker(searchPath)
 79	var matches []FileInfo
 80	conf := fastwalk.Config{
 81		Follow: true,
 82		// Use forward slashes when running a Windows binary under WSL or MSYS
 83		ToSlash: fastwalk.DefaultToSlash(),
 84		Sort:    fastwalk.SortFilesFirst,
 85	}
 86	err := fastwalk.Walk(&conf, searchPath, func(path string, d os.DirEntry, err error) error {
 87		if err != nil {
 88			return nil // Skip files we can't access
 89		}
 90
 91		if d.IsDir() {
 92			if walker.ShouldSkip(path) {
 93				return filepath.SkipDir
 94			}
 95			return nil
 96		}
 97
 98		if walker.ShouldSkip(path) {
 99			return nil
100		}
101
102		// Check if path matches the pattern
103		relPath, err := filepath.Rel(searchPath, path)
104		if err != nil {
105			relPath = path
106		}
107
108		matched, err := doublestar.Match(pattern, relPath)
109		if err != nil || !matched {
110			return nil
111		}
112
113		info, err := d.Info()
114		if err != nil {
115			return nil
116		}
117
118		matches = append(matches, FileInfo{Path: path, ModTime: info.ModTime()})
119		if limit > 0 && len(matches) >= limit*2 {
120			return filepath.SkipAll
121		}
122		return nil
123	})
124	if err != nil {
125		return nil, false, fmt.Errorf("fastwalk error: %w", err)
126	}
127
128	sort.Slice(matches, func(i, j int) bool {
129		return matches[i].ModTime.After(matches[j].ModTime)
130	})
131
132	truncated := false
133	if limit > 0 && len(matches) > limit {
134		matches = matches[:limit]
135		truncated = true
136	}
137
138	results := make([]string, len(matches))
139	for i, m := range matches {
140		results[i] = m.Path
141	}
142	return results, truncated, nil
143}
144
145// ShouldExcludeFile checks if a file should be excluded from processing
146// based on common patterns and ignore rules
147func ShouldExcludeFile(rootPath, filePath string) bool {
148	return NewDirectoryLister(rootPath).
149		shouldIgnore(filePath, nil)
150}
151
152// WalkDirectories walks a directory tree and calls the provided function for each directory,
153// respecting hierarchical .gitignore/.crushignore files like git does.
154func WalkDirectories(rootPath string, fn func(path string, d os.DirEntry, err error) error) error {
155	dl := NewDirectoryLister(rootPath)
156
157	conf := fastwalk.Config{
158		Follow:  true,
159		ToSlash: fastwalk.DefaultToSlash(),
160		Sort:    fastwalk.SortDirsFirst,
161	}
162
163	return fastwalk.Walk(&conf, rootPath, func(path string, d os.DirEntry, err error) error {
164		if err != nil {
165			return fn(path, d, err)
166		}
167
168		// Only process directories
169		if !d.IsDir() {
170			return nil
171		}
172
173		// Check if directory should be ignored
174		if dl.shouldIgnore(path, nil) {
175			return filepath.SkipDir
176		}
177
178		return fn(path, d, err)
179	})
180}
181
182func PrettyPath(path string) string {
183	return home.Short(path)
184}
185
186func DirTrim(pwd string, lim int) string {
187	var (
188		out string
189		sep = string(filepath.Separator)
190	)
191	dirs := strings.Split(pwd, sep)
192	if lim > len(dirs)-1 || lim <= 0 {
193		return pwd
194	}
195	for i := len(dirs) - 1; i > 0; i-- {
196		out = sep + out
197		if i == len(dirs)-1 {
198			out = dirs[i]
199		} else if i >= len(dirs)-lim {
200			out = string(dirs[i][0]) + out
201		} else {
202			out = "..." + out
203			break
204		}
205	}
206	out = filepath.Join("~", out)
207	return out
208}
209
210// PathOrPrefix returns the prefix if the path starts with it, or falls back to
211// the path otherwise.
212func PathOrPrefix(path, prefix string) string {
213	if HasPrefix(path, prefix) {
214		return prefix
215	}
216	return path
217}
218
219// HasPrefix checks if the given path starts with the specified prefix.
220// Uses filepath.Rel to determine if path is within prefix.
221func HasPrefix(path, prefix string) bool {
222	rel, err := filepath.Rel(prefix, path)
223	if err != nil {
224		return false
225	}
226	// If path is within prefix, Rel will not return a path starting with ".."
227	return !strings.HasPrefix(rel, "..")
228}
229
230// ToUnixLineEndings converts Windows line endings (CRLF) to Unix line endings (LF).
231func ToUnixLineEndings(content string) (string, bool) {
232	if strings.Contains(content, "\r\n") {
233		return strings.ReplaceAll(content, "\r\n", "\n"), true
234	}
235	return content, false
236}
237
238// ToWindowsLineEndings converts Unix line endings (LF) to Windows line endings (CRLF).
239func ToWindowsLineEndings(content string) (string, bool) {
240	if !strings.Contains(content, "\r\n") {
241		return strings.ReplaceAll(content, "\n", "\r\n"), true
242	}
243	return content, false
244}