fileutil.go

  1package fsext
  2
  3import (
  4	"errors"
  5	"fmt"
  6	"os"
  7	"path/filepath"
  8	"slices"
  9	"strings"
 10	"time"
 11
 12	"github.com/bmatcuk/doublestar/v4"
 13	"github.com/charlievieth/fastwalk"
 14	"github.com/charmbracelet/crush/internal/csync"
 15	"github.com/charmbracelet/crush/internal/home"
 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
 61// It uses hierarchical ignore checking like git does, checking .gitignore/.crushignore
 62// files in each directory from the root to the target path.
 63type FastGlobWalker struct {
 64	directoryLister *directoryLister
 65}
 66
 67func NewFastGlobWalker(searchPath string) *FastGlobWalker {
 68	return &FastGlobWalker{
 69		directoryLister: NewDirectoryLister(searchPath),
 70	}
 71}
 72
 73// ShouldSkip checks if a file path should be skipped based on hierarchical gitignore,
 74// crushignore, and hidden file rules.
 75func (w *FastGlobWalker) ShouldSkip(path string) bool {
 76	return w.directoryLister.shouldIgnore(path, nil, false)
 77}
 78
 79// ShouldSkipDir checks if a directory path should be skipped based on hierarchical
 80// gitignore, crushignore, and hidden file rules.
 81func (w *FastGlobWalker) ShouldSkipDir(path string) bool {
 82	return w.directoryLister.shouldIgnore(path, nil, true)
 83}
 84
 85// Glob globs files.
 86//
 87// Does not respect gitignore.
 88func Glob(pattern string, cwd string, limit int) ([]string, bool, error) {
 89	return globWithDoubleStar(pattern, cwd, limit, false)
 90}
 91
 92// GlobGitignoreAware globs files respecting gitignore.
 93func GlobGitignoreAware(pattern string, cwd string, limit int) ([]string, bool, error) {
 94	return globWithDoubleStar(pattern, cwd, limit, true)
 95}
 96
 97func globWithDoubleStar(pattern, searchPath string, limit int, gitignore bool) ([]string, bool, error) {
 98	// Normalize pattern to forward slashes on Windows so their config can use
 99	// backslashes
100	pattern = filepath.ToSlash(pattern)
101
102	walker := NewFastGlobWalker(searchPath)
103	found := csync.NewSlice[FileInfo]()
104	conf := fastwalk.Config{
105		Follow:  true,
106		ToSlash: fastwalk.DefaultToSlash(),
107		Sort:    fastwalk.SortFilesFirst,
108	}
109	err := fastwalk.Walk(&conf, searchPath, func(path string, d os.DirEntry, err error) error {
110		if err != nil {
111			return nil // Skip files we can't access
112		}
113
114		isDir := d.IsDir()
115		if isDir {
116			if gitignore && walker.ShouldSkipDir(path) {
117				return filepath.SkipDir
118			}
119		} else {
120			if gitignore && walker.ShouldSkip(path) {
121				return nil
122			}
123		}
124
125		relPath, err := filepath.Rel(searchPath, path)
126		if err != nil {
127			relPath = path
128		}
129
130		// Normalize separators to forward slashes
131		relPath = filepath.ToSlash(relPath)
132
133		// Check if path matches the pattern
134		matched, err := doublestar.Match(pattern, relPath)
135		if err != nil || !matched {
136			return nil
137		}
138
139		info, err := d.Info()
140		if err != nil {
141			return nil
142		}
143
144		found.Append(FileInfo{Path: path, ModTime: info.ModTime()})
145		if limit > 0 && found.Len() >= limit*2 { // NOTE: why x2?
146			return filepath.SkipAll
147		}
148		return nil
149	})
150	if err != nil && !errors.Is(err, filepath.SkipAll) {
151		return nil, false, fmt.Errorf("fastwalk error: %w", err)
152	}
153
154	matches := slices.SortedFunc(found.Seq(), func(a, b FileInfo) int {
155		return b.ModTime.Compare(a.ModTime)
156	})
157	matches, truncated := truncate(matches, limit)
158
159	results := make([]string, len(matches))
160	for i, m := range matches {
161		results[i] = m.Path
162	}
163	return results, truncated || errors.Is(err, filepath.SkipAll), nil
164}
165
166// ShouldExcludeFile checks if a file should be excluded from processing
167// based on common patterns and ignore rules.
168func ShouldExcludeFile(rootPath, filePath string) bool {
169	info, err := os.Stat(filePath)
170	isDir := err == nil && info.IsDir()
171	return NewDirectoryLister(rootPath).
172		shouldIgnore(filePath, nil, isDir)
173}
174
175func PrettyPath(path string) string {
176	return home.Short(path)
177}
178
179func DirTrim(pwd string, lim int) string {
180	var (
181		out string
182		sep = string(filepath.Separator)
183	)
184	dirs := strings.Split(pwd, sep)
185	if lim > len(dirs)-1 || lim <= 0 {
186		return pwd
187	}
188	for i := len(dirs) - 1; i > 0; i-- {
189		out = sep + out
190		if i == len(dirs)-1 {
191			out = dirs[i]
192		} else if i >= len(dirs)-lim {
193			out = string(dirs[i][0]) + out
194		} else {
195			out = "..." + out
196			break
197		}
198	}
199	out = filepath.Join("~", out)
200	return out
201}
202
203// PathOrPrefix returns the prefix if the path starts with it, or falls back to
204// the path otherwise.
205func PathOrPrefix(path, prefix string) string {
206	if HasPrefix(path, prefix) {
207		return prefix
208	}
209	return path
210}
211
212// HasPrefix checks if the given path starts with the specified prefix.
213// Uses filepath.Rel to determine if path is within prefix.
214func HasPrefix(path, prefix string) bool {
215	rel, err := filepath.Rel(prefix, path)
216	if err != nil {
217		return false
218	}
219	// If path is within prefix, Rel will not return a path starting with ".."
220	return !strings.HasPrefix(rel, "..")
221}
222
223// ToUnixLineEndings converts Windows line endings (CRLF) to Unix line endings (LF).
224func ToUnixLineEndings(content string) (string, bool) {
225	if strings.Contains(content, "\r\n") {
226		return strings.ReplaceAll(content, "\r\n", "\n"), true
227	}
228	return content, false
229}
230
231// ToWindowsLineEndings converts Unix line endings (LF) to Windows line endings (CRLF).
232func ToWindowsLineEndings(content string) (string, bool) {
233	if !strings.Contains(content, "\r\n") {
234		return strings.ReplaceAll(content, "\n", "\r\n"), true
235	}
236	return content, false
237}
238
239func truncate[T any](input []T, limit int) ([]T, bool) {
240	if limit > 0 && len(input) > limit {
241		return input[:limit], true
242	}
243	return input, false
244}