discover.go

  1package setup
  2
  3import (
  4	"fmt"
  5	"os"
  6	"os/exec"
  7	"path/filepath"
  8	"runtime"
  9	"strings"
 10
 11	"github.com/opencode-ai/opencode/internal/lsp/protocol"
 12)
 13
 14// LSPServerInfo contains information about an LSP server
 15type LSPServerInfo struct {
 16	Name        string   // Display name of the server
 17	Command     string   // Command to execute
 18	Args        []string // Arguments to pass to the command
 19	InstallCmd  string   // Command to install the server
 20	Description string   // Description of the server
 21	Recommended bool     // Whether this is the recommended server for the language
 22	Options     any      // Additional options for the server
 23}
 24
 25// LSPServerMap maps languages to their available LSP servers
 26type LSPServerMap map[protocol.LanguageKind][]LSPServerInfo
 27
 28// ServerDefinition defines an LSP server configuration
 29type ServerDefinition struct {
 30	Name       string
 31	Args       []string
 32	InstallCmd string
 33	Languages  []protocol.LanguageKind
 34}
 35
 36// Common paths where LSP servers might be installed
 37var (
 38	// Common editor-specific paths
 39	vscodePath = getVSCodeExtensionsPath()
 40	neovimPath = getNeovimPluginsPath()
 41
 42	// Common package manager paths
 43	npmBinPath       = getNpmGlobalBinPath()
 44	pipBinPath       = getPipBinPath()
 45	goBinPath        = getGoBinPath()
 46	cargoInstallPath = getCargoInstallPath()
 47
 48	// Server definitions
 49	serverDefinitions = []ServerDefinition{
 50		{
 51			Name:       "typescript-language-server",
 52			Args:       []string{"--stdio"},
 53			InstallCmd: "npm install -g typescript-language-server typescript",
 54			Languages:  []protocol.LanguageKind{protocol.LangJavaScript, protocol.LangTypeScript, protocol.LangJavaScriptReact, protocol.LangTypeScriptReact},
 55		},
 56		{
 57			Name:       "deno",
 58			Args:       []string{"lsp"},
 59			InstallCmd: "https://deno.com/#installation",
 60			Languages:  []protocol.LanguageKind{protocol.LangJavaScript, protocol.LangTypeScript},
 61		},
 62		{
 63			Name:       "pylsp",
 64			Args:       []string{},
 65			InstallCmd: "pip install python-lsp-server",
 66			Languages:  []protocol.LanguageKind{protocol.LangPython},
 67		},
 68		{
 69			Name:       "pyright",
 70			Args:       []string{"--stdio"},
 71			InstallCmd: "npm install -g pyright",
 72			Languages:  []protocol.LanguageKind{protocol.LangPython},
 73		},
 74		{
 75			Name:       "jedi-language-server",
 76			Args:       []string{},
 77			InstallCmd: "pip install jedi-language-server",
 78			Languages:  []protocol.LanguageKind{protocol.LangPython},
 79		},
 80		{
 81			Name:       "gopls",
 82			Args:       []string{},
 83			InstallCmd: "go install golang.org/x/tools/gopls@latest",
 84			Languages:  []protocol.LanguageKind{protocol.LangGo},
 85		},
 86		{
 87			Name:       "rust-analyzer",
 88			Args:       []string{},
 89			InstallCmd: "rustup component add rust-analyzer",
 90			Languages:  []protocol.LanguageKind{protocol.LangRust},
 91		},
 92		{
 93			Name:       "jdtls",
 94			Args:       []string{},
 95			InstallCmd: "Manual installation required: https://github.com/eclipse/eclipse.jdt.ls",
 96			Languages:  []protocol.LanguageKind{protocol.LangJava},
 97		},
 98		{
 99			Name:       "clangd",
100			Args:       []string{},
101			InstallCmd: "Manual installation required: Install via package manager or https://clangd.llvm.org/installation.html",
102			Languages:  []protocol.LanguageKind{protocol.LangC, protocol.LangCPP},
103		},
104		{
105			Name:       "omnisharp",
106			Args:       []string{"--languageserver"},
107			InstallCmd: "npm install -g omnisharp-language-server",
108			Languages:  []protocol.LanguageKind{protocol.LangCSharp},
109		},
110		{
111			Name:       "intelephense",
112			Args:       []string{"--stdio"},
113			InstallCmd: "npm install -g intelephense",
114			Languages:  []protocol.LanguageKind{protocol.LangPHP},
115		},
116		{
117			Name:       "solargraph",
118			Args:       []string{"stdio"},
119			InstallCmd: "gem install solargraph",
120			Languages:  []protocol.LanguageKind{protocol.LangRuby},
121		},
122		{
123			Name:       "vscode-html-language-server",
124			Args:       []string{"--stdio"},
125			InstallCmd: "npm install -g vscode-langservers-extracted",
126			Languages:  []protocol.LanguageKind{protocol.LangHTML},
127		},
128		{
129			Name:       "vscode-css-language-server",
130			Args:       []string{"--stdio"},
131			InstallCmd: "npm install -g vscode-langservers-extracted",
132			Languages:  []protocol.LanguageKind{protocol.LangCSS},
133		},
134		{
135			Name:       "vscode-json-language-server",
136			Args:       []string{"--stdio"},
137			InstallCmd: "npm install -g vscode-langservers-extracted",
138			Languages:  []protocol.LanguageKind{protocol.LangJSON},
139		},
140		{
141			Name:       "yaml-language-server",
142			Args:       []string{"--stdio"},
143			InstallCmd: "npm install -g yaml-language-server",
144			Languages:  []protocol.LanguageKind{protocol.LangYAML},
145		},
146		{
147			Name:       "lua-language-server",
148			Args:       []string{},
149			InstallCmd: "https://github.com/LuaLS/lua-language-server/wiki/Getting-Started",
150			Languages:  []protocol.LanguageKind{protocol.LangLua},
151		},
152		{
153			Name:       "docker-langserver",
154			Args:       []string{"--stdio"},
155			InstallCmd: "npm install -g dockerfile-language-server-nodejs",
156			Languages:  []protocol.LanguageKind{protocol.LangDockerfile},
157		},
158		{
159			Name:       "bash-language-server",
160			Args:       []string{"start"},
161			InstallCmd: "npm install -g bash-language-server",
162			Languages:  []protocol.LanguageKind{protocol.LangShellScript},
163		},
164		{
165			Name:       "vls",
166			Args:       []string{},
167			InstallCmd: "npm install -g @volar/vue-language-server",
168			Languages:  []protocol.LanguageKind{"vue"},
169		},
170		{
171			Name:       "svelteserver",
172			Args:       []string{"--stdio"},
173			InstallCmd: "npm install -g svelte-language-server",
174			Languages:  []protocol.LanguageKind{"svelte"},
175		},
176		{
177			Name:       "dart",
178			Args:       []string{"language-server"},
179			InstallCmd: "https://dart.dev/get-dart",
180			Languages:  []protocol.LanguageKind{protocol.LangDart},
181		},
182		{
183			Name:       "elixir-ls",
184			Args:       []string{},
185			InstallCmd: "https://github.com/elixir-lsp/elixir-ls#installation",
186			Languages:  []protocol.LanguageKind{protocol.LangElixir},
187		},
188	}
189
190	// Recommended servers by language
191	recommendedServers = map[protocol.LanguageKind]string{
192		protocol.LangJavaScript:      "typescript-language-server",
193		protocol.LangTypeScript:      "typescript-language-server",
194		protocol.LangJavaScriptReact: "typescript-language-server",
195		protocol.LangTypeScriptReact: "typescript-language-server",
196		protocol.LangPython:          "pylsp",
197		protocol.LangGo:              "gopls",
198		protocol.LangRust:            "rust-analyzer",
199		protocol.LangJava:            "jdtls",
200		protocol.LangC:               "clangd",
201		protocol.LangCPP:             "clangd",
202		protocol.LangCSharp:          "omnisharp",
203		protocol.LangPHP:             "intelephense",
204		protocol.LangRuby:            "solargraph",
205		protocol.LangHTML:            "vscode-html-language-server",
206		protocol.LangCSS:             "vscode-css-language-server",
207		protocol.LangJSON:            "vscode-json-language-server",
208		protocol.LangYAML:            "yaml-language-server",
209		protocol.LangLua:             "lua-language-server",
210		protocol.LangDockerfile:      "docker-langserver",
211		protocol.LangShellScript:     "bash-language-server",
212		"vue":                        "vls",
213		"svelte":                     "svelteserver",
214		protocol.LangDart:            "dart",
215		protocol.LangElixir:          "elixir-ls",
216	}
217)
218
219// DiscoverInstalledLSPs checks common locations for installed LSP servers
220func DiscoverInstalledLSPs() LSPServerMap {
221	result := make(LSPServerMap)
222
223	for _, def := range serverDefinitions {
224		for _, lang := range def.Languages {
225			checkAndAddServer(result, lang, def.Name, def.Args, def.InstallCmd)
226		}
227	}
228
229	return result
230}
231
232// checkAndAddServer checks if an LSP server is installed and adds it to the result map
233func checkAndAddServer(result LSPServerMap, lang protocol.LanguageKind, command string, args []string, installCmd string) {
234	// Check if the command exists in PATH
235	if path, err := exec.LookPath(command); err == nil {
236		server := LSPServerInfo{
237			Name:        command,
238			Command:     path,
239			Args:        args,
240			InstallCmd:  installCmd,
241			Description: fmt.Sprintf("%s language server", lang),
242			Recommended: isRecommendedServer(lang, command),
243		}
244
245		result[lang] = append(result[lang], server)
246	} else {
247		// Check in common editor-specific paths
248		if path := findInEditorPaths(command); path != "" {
249			server := LSPServerInfo{
250				Name:        command,
251				Command:     path,
252				Args:        args,
253				InstallCmd:  installCmd,
254				Description: fmt.Sprintf("%s language server", lang),
255				Recommended: isRecommendedServer(lang, command),
256			}
257			result[lang] = append(result[lang], server)
258		}
259	}
260}
261
262// findInEditorPaths checks for an LSP server in common editor-specific paths
263func findInEditorPaths(command string) string {
264	// Check in VSCode extensions
265	if vscodePath != "" {
266		// VSCode extensions can have different structures, so we need to search for the binary
267		matches, err := filepath.Glob(filepath.Join(vscodePath, "*", "**", command))
268		if err == nil && len(matches) > 0 {
269			for _, match := range matches {
270				if isExecutable(match) {
271					return match
272				}
273			}
274		}
275
276		// Check for node_modules/.bin in VSCode extensions
277		matches, err = filepath.Glob(filepath.Join(vscodePath, "*", "node_modules", ".bin", command))
278		if err == nil && len(matches) > 0 {
279			for _, match := range matches {
280				if isExecutable(match) {
281					return match
282				}
283			}
284		}
285	}
286
287	// Check in Neovim plugins
288	if neovimPath != "" {
289		matches, err := filepath.Glob(filepath.Join(neovimPath, "*", "**", command))
290		if err == nil && len(matches) > 0 {
291			for _, match := range matches {
292				if isExecutable(match) {
293					return match
294				}
295			}
296		}
297	}
298
299	// Check in npm global bin
300	if npmBinPath != "" {
301		path := filepath.Join(npmBinPath, command)
302		if isExecutable(path) {
303			return path
304		}
305	}
306
307	// Check in pip bin
308	if pipBinPath != "" {
309		path := filepath.Join(pipBinPath, command)
310		if isExecutable(path) {
311			return path
312		}
313	}
314
315	// Check in Go bin
316	if goBinPath != "" {
317		path := filepath.Join(goBinPath, command)
318		if isExecutable(path) {
319			return path
320		}
321	}
322
323	// Check in Cargo install
324	if cargoInstallPath != "" {
325		path := filepath.Join(cargoInstallPath, command)
326		if isExecutable(path) {
327			return path
328		}
329	}
330
331	return ""
332}
333
334// isExecutable checks if a file is executable
335func isExecutable(path string) bool {
336	info, err := os.Stat(path)
337	if err != nil {
338		return false
339	}
340
341	// On Windows, all files are "executable"
342	if runtime.GOOS == "windows" {
343		return !info.IsDir()
344	}
345
346	// On Unix-like systems, check the executable bit
347	return !info.IsDir() && (info.Mode()&0111 != 0)
348}
349
350// isRecommendedServer checks if a server is the recommended one for a language
351func isRecommendedServer(lang protocol.LanguageKind, command string) bool {
352	recommended, ok := recommendedServers[lang]
353	return ok && recommended == command
354}
355
356// GetRecommendedLSPServers returns the recommended LSP servers for the given languages
357func GetRecommendedLSPServers(languages []LanguageScore) LSPServerMap {
358	result := make(LSPServerMap)
359
360	for _, lang := range languages {
361		// Find the server definition for this language
362		for _, def := range serverDefinitions {
363			for _, defLang := range def.Languages {
364				if defLang == lang.Language && isRecommendedServer(lang.Language, def.Name) {
365					server := LSPServerInfo{
366						Name:        def.Name,
367						Command:     def.Name,
368						Args:        def.Args,
369						InstallCmd:  def.InstallCmd,
370						Description: fmt.Sprintf("%s Language Server", lang.Language),
371						Recommended: true,
372					}
373					result[lang.Language] = []LSPServerInfo{server}
374					break
375				}
376			}
377		}
378	}
379
380	return result
381}
382
383// Helper functions to get common paths
384
385func getVSCodeExtensionsPath() string {
386	var path string
387
388	switch runtime.GOOS {
389	case "windows":
390		path = filepath.Join(os.Getenv("USERPROFILE"), ".vscode", "extensions")
391	case "darwin":
392		path = filepath.Join(os.Getenv("HOME"), ".vscode", "extensions")
393	default: // Linux and others
394		path = filepath.Join(os.Getenv("HOME"), ".vscode", "extensions")
395	}
396
397	if _, err := os.Stat(path); err != nil {
398		// Try alternative locations
399		switch runtime.GOOS {
400		case "darwin":
401			altPath := filepath.Join(os.Getenv("HOME"), "Library", "Application Support", "Code", "User", "extensions")
402			if _, err := os.Stat(altPath); err == nil {
403				return altPath
404			}
405		case "linux":
406			altPath := filepath.Join(os.Getenv("HOME"), ".config", "Code", "User", "extensions")
407			if _, err := os.Stat(altPath); err == nil {
408				return altPath
409			}
410		}
411		return ""
412	}
413
414	return path
415}
416
417func getNeovimPluginsPath() string {
418	var paths []string
419
420	switch runtime.GOOS {
421	case "windows":
422		paths = []string{
423			filepath.Join(os.Getenv("LOCALAPPDATA"), "nvim", "plugged"),
424			filepath.Join(os.Getenv("LOCALAPPDATA"), "nvim", "site", "pack"),
425		}
426	default: // Linux, macOS, and others
427		paths = []string{
428			filepath.Join(os.Getenv("HOME"), ".local", "share", "nvim", "plugged"),
429			filepath.Join(os.Getenv("HOME"), ".local", "share", "nvim", "site", "pack"),
430			filepath.Join(os.Getenv("HOME"), ".config", "nvim", "plugged"),
431		}
432	}
433
434	for _, path := range paths {
435		if _, err := os.Stat(path); err == nil {
436			return path
437		}
438	}
439
440	return ""
441}
442
443func getNpmGlobalBinPath() string {
444	// Try to get the npm global bin path
445	cmd := exec.Command("npm", "config", "get", "prefix")
446	output, err := cmd.Output()
447	if err == nil {
448		prefix := strings.TrimSpace(string(output))
449		if prefix != "" {
450			if runtime.GOOS == "windows" {
451				return filepath.Join(prefix, "node_modules", ".bin")
452			}
453			return filepath.Join(prefix, "bin")
454		}
455	}
456
457	// Fallback to common locations
458	switch runtime.GOOS {
459	case "windows":
460		return filepath.Join(os.Getenv("APPDATA"), "npm")
461	default:
462		return filepath.Join(os.Getenv("HOME"), ".npm-global", "bin")
463	}
464}
465
466func getPipBinPath() string {
467	// Try to get the pip user bin path
468	var cmd *exec.Cmd
469	if runtime.GOOS == "windows" {
470		cmd = exec.Command("python", "-m", "site", "--user-base")
471	} else {
472		cmd = exec.Command("python3", "-m", "site", "--user-base")
473	}
474
475	output, err := cmd.Output()
476	if err == nil {
477		userBase := strings.TrimSpace(string(output))
478		if userBase != "" {
479			return filepath.Join(userBase, "bin")
480		}
481	}
482
483	// Fallback to common locations
484	switch runtime.GOOS {
485	case "windows":
486		return filepath.Join(os.Getenv("APPDATA"), "Python", "Scripts")
487	default:
488		return filepath.Join(os.Getenv("HOME"), ".local", "bin")
489	}
490}
491
492func getGoBinPath() string {
493	// Try to get the GOPATH
494	gopath := os.Getenv("GOPATH")
495	if gopath == "" {
496		// Fallback to default GOPATH
497		switch runtime.GOOS {
498		case "windows":
499			gopath = filepath.Join(os.Getenv("USERPROFILE"), "go")
500		default:
501			gopath = filepath.Join(os.Getenv("HOME"), "go")
502		}
503	}
504
505	return filepath.Join(gopath, "bin")
506}
507
508func getCargoInstallPath() string {
509	// Try to get the Cargo install path
510	cargoHome := os.Getenv("CARGO_HOME")
511	if cargoHome == "" {
512		// Fallback to default Cargo home
513		switch runtime.GOOS {
514		case "windows":
515			cargoHome = filepath.Join(os.Getenv("USERPROFILE"), ".cargo")
516		default:
517			cargoHome = filepath.Join(os.Getenv("HOME"), ".cargo")
518		}
519	}
520
521	return filepath.Join(cargoHome, "bin")
522}