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