fileutil.go

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