fileutil.go

  1package fileutil
  2
  3import (
  4	"fmt"
  5	"io/fs"
  6	"os"
  7	"os/exec"
  8	"path/filepath"
  9	"sort"
 10	"strings"
 11	"time"
 12
 13	"github.com/bmatcuk/doublestar/v4"
 14	"github.com/opencode-ai/opencode/internal/logging"
 15)
 16
 17var (
 18	rgPath  string
 19	fzfPath string
 20)
 21
 22func init() {
 23	var err error
 24	rgPath, err = exec.LookPath("rg")
 25	if err != nil {
 26		logging.Warn("Ripgrep (rg) not found in $PATH. Some features might be limited or slower.")
 27		rgPath = ""
 28	}
 29	fzfPath, err = exec.LookPath("fzf")
 30	if err != nil {
 31		logging.Warn("FZF not found in $PATH. Some features might be limited or slower.")
 32		fzfPath = ""
 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	cmd := exec.Command(rgPath, rgArgs...)
 52	cmd.Dir = "."
 53	return cmd
 54}
 55
 56func GetFzfCmd(query string) *exec.Cmd {
 57	if fzfPath == "" {
 58		return nil
 59	}
 60	fzfArgs := []string{
 61		"--filter",
 62		query,
 63		"--read0",
 64		"--print0",
 65	}
 66	cmd := exec.Command(fzfPath, fzfArgs...)
 67	cmd.Dir = "."
 68	return cmd
 69}
 70
 71type FileInfo struct {
 72	Path    string
 73	ModTime time.Time
 74}
 75
 76func SkipHidden(path string) bool {
 77	// Check for hidden files (starting with a dot)
 78	base := filepath.Base(path)
 79	if base != "." && strings.HasPrefix(base, ".") {
 80		return true
 81	}
 82
 83	commonIgnoredDirs := map[string]bool{
 84		".opencode":        true,
 85		"node_modules":     true,
 86		"vendor":           true,
 87		"dist":             true,
 88		"build":            true,
 89		"target":           true,
 90		".git":             true,
 91		".idea":            true,
 92		".vscode":          true,
 93		"__pycache__":      true,
 94		"bin":              true,
 95		"obj":              true,
 96		"out":              true,
 97		"coverage":         true,
 98		"tmp":              true,
 99		"temp":             true,
100		"logs":             true,
101		"generated":        true,
102		"bower_components": true,
103		"jspm_packages":    true,
104	}
105
106	parts := strings.Split(path, string(os.PathSeparator))
107	for _, part := range parts {
108		if commonIgnoredDirs[part] {
109			return true
110		}
111	}
112	return false
113}
114
115func GlobWithDoublestar(pattern, searchPath string, limit int) ([]string, bool, error) {
116	fsys := os.DirFS(searchPath)
117	relPattern := strings.TrimPrefix(pattern, "/")
118	var matches []FileInfo
119
120	err := doublestar.GlobWalk(fsys, relPattern, func(path string, d fs.DirEntry) error {
121		if d.IsDir() {
122			return nil
123		}
124		if SkipHidden(path) {
125			return nil
126		}
127		info, err := d.Info()
128		if err != nil {
129			return nil
130		}
131		absPath := path
132		if !strings.HasPrefix(absPath, searchPath) && searchPath != "." {
133			absPath = filepath.Join(searchPath, absPath)
134		} else if !strings.HasPrefix(absPath, "/") && searchPath == "." {
135			absPath = filepath.Join(searchPath, absPath) // Ensure relative paths are joined correctly
136		}
137
138		matches = append(matches, FileInfo{Path: absPath, ModTime: info.ModTime()})
139		if limit > 0 && len(matches) >= limit*2 {
140			return fs.SkipAll
141		}
142		return nil
143	})
144	if err != nil {
145		return nil, false, fmt.Errorf("glob walk error: %w", err)
146	}
147
148	sort.Slice(matches, func(i, j int) bool {
149		return matches[i].ModTime.After(matches[j].ModTime)
150	})
151
152	truncated := false
153	if limit > 0 && len(matches) > limit {
154		matches = matches[:limit]
155		truncated = true
156	}
157
158	results := make([]string, len(matches))
159	for i, m := range matches {
160		results[i] = m.Path
161	}
162	return results, truncated, nil
163}