detect.go

  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}