1package setup
2
3import (
4 "os"
5 "path/filepath"
6 "sort"
7 "strings"
8
9 "github.com/opencode-ai/opencode/internal/logging"
10 "github.com/opencode-ai/opencode/internal/lsp/protocol"
11)
12
13// LanguageScore represents a language with its importance score in the project
14type LanguageScore struct {
15 Language protocol.LanguageKind
16 Score int
17}
18
19// DetectProjectLanguages scans the workspace and returns a map of languages to their importance score
20func DetectProjectLanguages(workspaceDir string) (map[protocol.LanguageKind]int, error) {
21 languages := make(map[protocol.LanguageKind]int)
22
23 skipDirs := map[string]bool{
24 ".git": true,
25 "node_modules": true,
26 "vendor": true,
27 "dist": true,
28 "build": true,
29 ".idea": true,
30 ".vscode": true,
31 ".github": true,
32 ".gitlab": true,
33 ".next": true,
34 }
35
36 err := filepath.Walk(workspaceDir, func(path string, info os.FileInfo, err error) error {
37 if err != nil {
38 return nil
39 }
40
41 if info.IsDir() {
42 if skipDirs[info.Name()] {
43 return filepath.SkipDir
44 }
45 return nil
46 }
47
48 // Skip files larger than 1MB to avoid processing large binary files
49 if info.Size() > 1024*1024 {
50 return nil
51 }
52
53 // Skip hidden files
54 if strings.HasPrefix(info.Name(), ".") {
55 return nil
56 }
57
58 // Detect language based on file extension
59 lang := detectLanguageFromPath(path)
60 if lang != "" {
61 languages[lang]++
62 }
63
64 return nil
65 })
66 if err != nil {
67 return nil, err
68 }
69
70 // Check for special project files to boost language scores
71 checkSpecialProjectFiles(workspaceDir, languages)
72
73 return languages, nil
74}
75
76// detectLanguageFromPath detects the language based on the file path
77func detectLanguageFromPath(path string) protocol.LanguageKind {
78 ext := strings.ToLower(filepath.Ext(path))
79 filename := strings.ToLower(filepath.Base(path))
80
81 // Special case for Dockerfiles which don't have extensions
82 if filename == "dockerfile" || strings.HasSuffix(filename, ".dockerfile") {
83 return protocol.LangDockerfile
84 }
85
86 // Special case for Makefiles
87 if filename == "makefile" || strings.HasSuffix(filename, ".mk") {
88 return protocol.LangMakefile
89 }
90
91 // Special case for shell scripts without extensions
92 if isShellScript(path) {
93 return protocol.LangShellScript
94 }
95
96 // Map file extensions to languages
97 switch ext {
98 case ".go":
99 return protocol.LangGo
100 case ".js":
101 return protocol.LangJavaScript
102 case ".jsx":
103 return protocol.LangJavaScriptReact
104 case ".ts":
105 return protocol.LangTypeScript
106 case ".tsx":
107 return protocol.LangTypeScriptReact
108 case ".py":
109 return protocol.LangPython
110 case ".java":
111 return protocol.LangJava
112 case ".c":
113 return protocol.LangC
114 case ".cpp", ".cc", ".cxx", ".c++":
115 return protocol.LangCPP
116 case ".cs":
117 return protocol.LangCSharp
118 case ".php":
119 return protocol.LangPHP
120 case ".rb":
121 return protocol.LangRuby
122 case ".rs":
123 return protocol.LangRust
124 case ".swift":
125 return protocol.LangSwift
126 case ".kt", ".kts":
127 return "kotlin"
128 case ".scala":
129 return protocol.LangScala
130 case ".html", ".htm":
131 return protocol.LangHTML
132 case ".css":
133 return protocol.LangCSS
134 case ".scss":
135 return protocol.LangSCSS
136 case ".sass":
137 return protocol.LangSASS
138 case ".less":
139 return protocol.LangLess
140 case ".json":
141 return protocol.LangJSON
142 case ".xml":
143 return protocol.LangXML
144 case ".yaml", ".yml":
145 return protocol.LangYAML
146 case ".md", ".markdown":
147 return protocol.LangMarkdown
148 case ".sh", ".bash", ".zsh":
149 return protocol.LangShellScript
150 case ".sql":
151 return protocol.LangSQL
152 case ".dart":
153 return protocol.LangDart
154 case ".lua":
155 return protocol.LangLua
156 case ".ex", ".exs":
157 return protocol.LangElixir
158 case ".erl":
159 return protocol.LangErlang
160 case ".hs":
161 return protocol.LangHaskell
162 case ".pl", ".pm":
163 return protocol.LangPerl
164 case ".r":
165 return protocol.LangR
166 case ".vue":
167 return "vue"
168 case ".svelte":
169 return "svelte"
170 }
171
172 return ""
173}
174
175// isShellScript checks if a file is a shell script by looking at its shebang
176func isShellScript(path string) bool {
177 // Open the file
178 file, err := os.Open(path)
179 if err != nil {
180 return false
181 }
182 defer file.Close()
183
184 // Read the first line
185 buf := make([]byte, 128)
186 n, err := file.Read(buf)
187 if err != nil || n < 2 {
188 return false
189 }
190
191 // Check for shebang
192 if buf[0] == '#' && buf[1] == '!' {
193 line := string(buf[:n])
194 return strings.Contains(line, "/bin/sh") ||
195 strings.Contains(line, "/bin/bash") ||
196 strings.Contains(line, "/bin/zsh") ||
197 strings.Contains(line, "/usr/bin/env sh") ||
198 strings.Contains(line, "/usr/bin/env bash") ||
199 strings.Contains(line, "/usr/bin/env zsh")
200 }
201
202 return false
203}
204
205// checkSpecialProjectFiles looks for special project files to boost language scores
206func checkSpecialProjectFiles(workspaceDir string, languages map[protocol.LanguageKind]int) {
207 // Check for package.json (Node.js/JavaScript/TypeScript)
208 if _, err := os.Stat(filepath.Join(workspaceDir, "package.json")); err == nil {
209 languages[protocol.LangJavaScript] += 10
210
211 // Check for TypeScript configuration
212 if _, err := os.Stat(filepath.Join(workspaceDir, "tsconfig.json")); err == nil {
213 languages[protocol.LangTypeScript] += 15
214 }
215 }
216
217 // Check for go.mod (Go)
218 if _, err := os.Stat(filepath.Join(workspaceDir, "go.mod")); err == nil {
219 languages[protocol.LangGo] += 20
220 }
221
222 // Check for requirements.txt or setup.py (Python)
223 if _, err := os.Stat(filepath.Join(workspaceDir, "requirements.txt")); err == nil {
224 languages[protocol.LangPython] += 15
225 }
226 if _, err := os.Stat(filepath.Join(workspaceDir, "setup.py")); err == nil {
227 languages[protocol.LangPython] += 15
228 }
229
230 // Check for pom.xml or build.gradle (Java)
231 if _, err := os.Stat(filepath.Join(workspaceDir, "pom.xml")); err == nil {
232 languages[protocol.LangJava] += 15
233 }
234 if _, err := os.Stat(filepath.Join(workspaceDir, "build.gradle")); err == nil {
235 languages[protocol.LangJava] += 15
236 }
237
238 // Check for Cargo.toml (Rust)
239 if _, err := os.Stat(filepath.Join(workspaceDir, "Cargo.toml")); err == nil {
240 languages[protocol.LangRust] += 20
241 }
242
243 // Check for composer.json (PHP)
244 if _, err := os.Stat(filepath.Join(workspaceDir, "composer.json")); err == nil {
245 languages[protocol.LangPHP] += 15
246 }
247
248 // Check for Gemfile (Ruby)
249 if _, err := os.Stat(filepath.Join(workspaceDir, "Gemfile")); err == nil {
250 languages[protocol.LangRuby] += 15
251 }
252
253 // Check for CMakeLists.txt (C/C++)
254 if _, err := os.Stat(filepath.Join(workspaceDir, "CMakeLists.txt")); err == nil {
255 languages[protocol.LangCPP] += 10
256 languages[protocol.LangC] += 5
257 }
258
259 // Check for pubspec.yaml (Dart/Flutter)
260 if _, err := os.Stat(filepath.Join(workspaceDir, "pubspec.yaml")); err == nil {
261 languages["dart"] += 20
262 }
263
264 // Check for mix.exs (Elixir)
265 if _, err := os.Stat(filepath.Join(workspaceDir, "mix.exs")); err == nil {
266 languages[protocol.LangElixir] += 20
267 }
268}
269
270// GetPrimaryLanguages returns the top N languages in the project
271func GetPrimaryLanguages(languages map[protocol.LanguageKind]int, limit int) []LanguageScore {
272 // Convert map to slice for sorting
273 var langScores []LanguageScore
274 for lang, score := range languages {
275 if lang != "" && score > 0 {
276 langScores = append(langScores, LanguageScore{
277 Language: lang,
278 Score: score,
279 })
280 }
281 }
282
283 // Sort by score (descending)
284 sort.Slice(langScores, func(i, j int) bool {
285 return langScores[i].Score > langScores[j].Score
286 })
287
288 // Return top N languages or all if less than N
289 if len(langScores) <= limit {
290 return langScores
291 }
292 return langScores[:limit]
293}
294
295// DetectMonorepo checks if the workspace is a monorepo by looking for multiple project files
296func DetectMonorepo(workspaceDir string) (bool, []string) {
297 var projectDirs []string
298
299 // Common project files to look for
300 projectFiles := []string{
301 "package.json",
302 "go.mod",
303 "pom.xml",
304 "build.gradle",
305 "Cargo.toml",
306 "requirements.txt",
307 "setup.py",
308 "composer.json",
309 "Gemfile",
310 "pubspec.yaml",
311 "mix.exs",
312 }
313
314 // Skip directories that are typically not relevant
315 skipDirs := map[string]bool{
316 ".git": true,
317 "node_modules": true,
318 "vendor": true,
319 "dist": true,
320 "build": true,
321 ".idea": true,
322 ".vscode": true,
323 ".github": true,
324 ".gitlab": true,
325 }
326
327 // Check for root project files
328 rootIsProject := false
329 for _, file := range projectFiles {
330 if _, err := os.Stat(filepath.Join(workspaceDir, file)); err == nil {
331 rootIsProject = true
332 break
333 }
334 }
335
336 // Walk through the workspace to find project files in subdirectories
337 err := filepath.Walk(workspaceDir, func(path string, info os.FileInfo, err error) error {
338 if err != nil {
339 return nil
340 }
341
342 // Skip the root directory since we already checked it
343 if path == workspaceDir {
344 return nil
345 }
346
347 // Skip files
348 if !info.IsDir() {
349 return nil
350 }
351
352 // Skip directories in the skipDirs list
353 if skipDirs[info.Name()] {
354 return filepath.SkipDir
355 }
356
357 // Check for project files in this directory
358 for _, file := range projectFiles {
359 if _, err := os.Stat(filepath.Join(path, file)); err == nil {
360 // Found a project file, add this directory to the list
361 relPath, err := filepath.Rel(workspaceDir, path)
362 if err == nil {
363 projectDirs = append(projectDirs, relPath)
364 }
365 return filepath.SkipDir // Skip subdirectories of this project
366 }
367 }
368
369 return nil
370 })
371 if err != nil {
372 logging.Warn("Error detecting monorepo", "error", err)
373 }
374
375 // It's a monorepo if we found multiple project directories
376 isMonorepo := len(projectDirs) > 0
377
378 // If the root is also a project, add it to the list
379 if rootIsProject {
380 projectDirs = append([]string{"."}, projectDirs...)
381 }
382
383 return isMonorepo, projectDirs
384}