fileutil.go

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