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}