mirror of
https://github.com/aljazceru/opencode.git
synced 2025-12-23 18:54:21 +01:00
feat: lsp discovery
This commit is contained in:
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/opencode-ai/opencode/internal/db"
|
"github.com/opencode-ai/opencode/internal/db"
|
||||||
"github.com/opencode-ai/opencode/internal/llm/agent"
|
"github.com/opencode-ai/opencode/internal/llm/agent"
|
||||||
"github.com/opencode-ai/opencode/internal/logging"
|
"github.com/opencode-ai/opencode/internal/logging"
|
||||||
|
"github.com/opencode-ai/opencode/internal/lsp/discovery"
|
||||||
"github.com/opencode-ai/opencode/internal/pubsub"
|
"github.com/opencode-ai/opencode/internal/pubsub"
|
||||||
"github.com/opencode-ai/opencode/internal/tui"
|
"github.com/opencode-ai/opencode/internal/tui"
|
||||||
"github.com/opencode-ai/opencode/internal/version"
|
"github.com/opencode-ai/opencode/internal/version"
|
||||||
@@ -58,6 +59,12 @@ to assist developers in writing, debugging, and understanding code directly from
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run LSP auto-discovery
|
||||||
|
if err := discovery.IntegrateLSPServers(cwd); err != nil {
|
||||||
|
logging.Warn("Failed to auto-discover LSP servers", "error", err)
|
||||||
|
// Continue anyway, this is not a fatal error
|
||||||
|
}
|
||||||
|
|
||||||
// Connect DB, this will also run migrations
|
// Connect DB, this will also run migrations
|
||||||
conn, err := db.Connect()
|
conn, err := db.Connect()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ func CoderAgentTools(
|
|||||||
tools.NewViewTool(lspClients),
|
tools.NewViewTool(lspClients),
|
||||||
tools.NewPatchTool(lspClients, permissions, history),
|
tools.NewPatchTool(lspClients, permissions, history),
|
||||||
tools.NewWriteTool(lspClients, permissions, history),
|
tools.NewWriteTool(lspClients, permissions, history),
|
||||||
|
tools.NewConfigureLspServerTool(),
|
||||||
NewAgentTool(sessions, messages, lspClients),
|
NewAgentTool(sessions, messages, lspClients),
|
||||||
}, otherTools...,
|
}, otherTools...,
|
||||||
)
|
)
|
||||||
|
|||||||
49
internal/llm/tools/lsp.go
Normal file
49
internal/llm/tools/lsp.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/opencode-ai/opencode/internal/lsp/discovery/tool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConfigureLspServerTool is a tool for configuring LSP servers
|
||||||
|
type ConfigureLspServerTool struct{}
|
||||||
|
|
||||||
|
// NewConfigureLspServerTool creates a new ConfigureLspServerTool
|
||||||
|
func NewConfigureLspServerTool() *ConfigureLspServerTool {
|
||||||
|
return &ConfigureLspServerTool{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info returns information about the tool
|
||||||
|
func (t *ConfigureLspServerTool) Info() ToolInfo {
|
||||||
|
return ToolInfo{
|
||||||
|
Name: "configureLspServer",
|
||||||
|
Description: "Searches for an LSP server for the given language",
|
||||||
|
Parameters: map[string]any{
|
||||||
|
"language": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "The language identifier (e.g., \"go\", \"typescript\", \"python\")",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Required: []string{"language"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run executes the tool
|
||||||
|
func (t *ConfigureLspServerTool) Run(ctx context.Context, params ToolCall) (ToolResponse, error) {
|
||||||
|
result, err := tool.ConfigureLspServer(ctx, json.RawMessage(params.Input))
|
||||||
|
if err != nil {
|
||||||
|
return NewTextErrorResponse(err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert the result to JSON
|
||||||
|
resultJSON, err := json.MarshalIndent(result, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return NewTextErrorResponse(fmt.Sprintf("Failed to marshal result: %v", err)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return NewTextResponse(string(resultJSON)), nil
|
||||||
|
}
|
||||||
|
|
||||||
@@ -96,10 +96,10 @@ func NewClient(ctx context.Context, command string, args ...string) (*Client, er
|
|||||||
go func() {
|
go func() {
|
||||||
scanner := bufio.NewScanner(stderr)
|
scanner := bufio.NewScanner(stderr)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
fmt.Fprintf(os.Stderr, "LSP Server: %s\n", scanner.Text())
|
logging.Info("LSP Server", "message", scanner.Text())
|
||||||
}
|
}
|
||||||
if err := scanner.Err(); err != nil {
|
if err := scanner.Err(); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Error reading stderr: %v\n", err)
|
logging.Error("Error reading LSP stderr", "error", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
72
internal/lsp/discovery/integration.go
Normal file
72
internal/lsp/discovery/integration.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package discovery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/opencode-ai/opencode/internal/config"
|
||||||
|
"github.com/opencode-ai/opencode/internal/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IntegrateLSPServers discovers languages and LSP servers and integrates them into the application configuration
|
||||||
|
func IntegrateLSPServers(workingDir string) error {
|
||||||
|
// Get the current configuration
|
||||||
|
cfg := config.Get()
|
||||||
|
if cfg == nil {
|
||||||
|
return fmt.Errorf("config not loaded")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is the first run
|
||||||
|
shouldInit, err := config.ShouldShowInitDialog()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check initialization status: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always run language detection, but log differently for first run vs. subsequent runs
|
||||||
|
if shouldInit || len(cfg.LSP) == 0 {
|
||||||
|
logging.Info("Running initial LSP auto-discovery...")
|
||||||
|
} else {
|
||||||
|
logging.Debug("Running LSP auto-discovery to detect new languages...")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure LSP servers
|
||||||
|
servers, err := ConfigureLSPServers(workingDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to configure LSP servers: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the configuration with discovered servers
|
||||||
|
for langID, serverInfo := range servers {
|
||||||
|
// Skip languages that already have a configured server
|
||||||
|
if _, exists := cfg.LSP[langID]; exists {
|
||||||
|
logging.Debug("LSP server already configured for language", "language", langID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if serverInfo.Available {
|
||||||
|
// Only add servers that were found
|
||||||
|
cfg.LSP[langID] = config.LSPConfig{
|
||||||
|
Disabled: false,
|
||||||
|
Command: serverInfo.Path,
|
||||||
|
Args: serverInfo.Args,
|
||||||
|
}
|
||||||
|
logging.Info("Added LSP server to configuration",
|
||||||
|
"language", langID,
|
||||||
|
"command", serverInfo.Command,
|
||||||
|
"path", serverInfo.Path)
|
||||||
|
} else {
|
||||||
|
logging.Warn("LSP server not available",
|
||||||
|
"language", langID,
|
||||||
|
"command", serverInfo.Command,
|
||||||
|
"installCmd", serverInfo.InstallCmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark the project as initialized
|
||||||
|
if shouldInit {
|
||||||
|
if err := config.MarkProjectInitialized(); err != nil {
|
||||||
|
logging.Warn("Failed to mark project as initialized", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
298
internal/lsp/discovery/language.go
Normal file
298
internal/lsp/discovery/language.go
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
package discovery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/opencode-ai/opencode/internal/logging"
|
||||||
|
"github.com/opencode-ai/opencode/internal/lsp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LanguageInfo stores information about a detected language
|
||||||
|
type LanguageInfo struct {
|
||||||
|
// Language identifier (e.g., "go", "typescript", "python")
|
||||||
|
ID string
|
||||||
|
|
||||||
|
// Number of files detected for this language
|
||||||
|
FileCount int
|
||||||
|
|
||||||
|
// Project files associated with this language (e.g., go.mod, package.json)
|
||||||
|
ProjectFiles []string
|
||||||
|
|
||||||
|
// Whether this is likely a primary language in the project
|
||||||
|
IsPrimary bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectFile represents a project configuration file
|
||||||
|
type ProjectFile struct {
|
||||||
|
// File name or pattern to match
|
||||||
|
Name string
|
||||||
|
|
||||||
|
// Associated language ID
|
||||||
|
LanguageID string
|
||||||
|
|
||||||
|
// Whether this file strongly indicates the language is primary
|
||||||
|
IsPrimary bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common project files that indicate specific languages
|
||||||
|
var projectFilePatterns = []ProjectFile{
|
||||||
|
{Name: "go.mod", LanguageID: "go", IsPrimary: true},
|
||||||
|
{Name: "go.sum", LanguageID: "go", IsPrimary: false},
|
||||||
|
{Name: "package.json", LanguageID: "javascript", IsPrimary: true}, // Could be TypeScript too
|
||||||
|
{Name: "tsconfig.json", LanguageID: "typescript", IsPrimary: true},
|
||||||
|
{Name: "jsconfig.json", LanguageID: "javascript", IsPrimary: true},
|
||||||
|
{Name: "pyproject.toml", LanguageID: "python", IsPrimary: true},
|
||||||
|
{Name: "setup.py", LanguageID: "python", IsPrimary: true},
|
||||||
|
{Name: "requirements.txt", LanguageID: "python", IsPrimary: true},
|
||||||
|
{Name: "Cargo.toml", LanguageID: "rust", IsPrimary: true},
|
||||||
|
{Name: "Cargo.lock", LanguageID: "rust", IsPrimary: false},
|
||||||
|
{Name: "CMakeLists.txt", LanguageID: "cmake", IsPrimary: true},
|
||||||
|
{Name: "pom.xml", LanguageID: "java", IsPrimary: true},
|
||||||
|
{Name: "build.gradle", LanguageID: "java", IsPrimary: true},
|
||||||
|
{Name: "build.gradle.kts", LanguageID: "kotlin", IsPrimary: true},
|
||||||
|
{Name: "composer.json", LanguageID: "php", IsPrimary: true},
|
||||||
|
{Name: "Gemfile", LanguageID: "ruby", IsPrimary: true},
|
||||||
|
{Name: "Rakefile", LanguageID: "ruby", IsPrimary: true},
|
||||||
|
{Name: "mix.exs", LanguageID: "elixir", IsPrimary: true},
|
||||||
|
{Name: "rebar.config", LanguageID: "erlang", IsPrimary: true},
|
||||||
|
{Name: "dune-project", LanguageID: "ocaml", IsPrimary: true},
|
||||||
|
{Name: "stack.yaml", LanguageID: "haskell", IsPrimary: true},
|
||||||
|
{Name: "cabal.project", LanguageID: "haskell", IsPrimary: true},
|
||||||
|
{Name: "Makefile", LanguageID: "make", IsPrimary: false},
|
||||||
|
{Name: "Dockerfile", LanguageID: "dockerfile", IsPrimary: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map of file extensions to language IDs
|
||||||
|
var extensionToLanguage = map[string]string{
|
||||||
|
".go": "go",
|
||||||
|
".js": "javascript",
|
||||||
|
".jsx": "javascript",
|
||||||
|
".ts": "typescript",
|
||||||
|
".tsx": "typescript",
|
||||||
|
".py": "python",
|
||||||
|
".rs": "rust",
|
||||||
|
".java": "java",
|
||||||
|
".c": "c",
|
||||||
|
".cpp": "cpp",
|
||||||
|
".h": "c",
|
||||||
|
".hpp": "cpp",
|
||||||
|
".rb": "ruby",
|
||||||
|
".php": "php",
|
||||||
|
".cs": "csharp",
|
||||||
|
".fs": "fsharp",
|
||||||
|
".swift": "swift",
|
||||||
|
".kt": "kotlin",
|
||||||
|
".scala": "scala",
|
||||||
|
".hs": "haskell",
|
||||||
|
".ml": "ocaml",
|
||||||
|
".ex": "elixir",
|
||||||
|
".exs": "elixir",
|
||||||
|
".erl": "erlang",
|
||||||
|
".lua": "lua",
|
||||||
|
".r": "r",
|
||||||
|
".sh": "shell",
|
||||||
|
".bash": "shell",
|
||||||
|
".zsh": "shell",
|
||||||
|
".html": "html",
|
||||||
|
".css": "css",
|
||||||
|
".scss": "scss",
|
||||||
|
".sass": "sass",
|
||||||
|
".less": "less",
|
||||||
|
".json": "json",
|
||||||
|
".xml": "xml",
|
||||||
|
".yaml": "yaml",
|
||||||
|
".yml": "yaml",
|
||||||
|
".md": "markdown",
|
||||||
|
".dart": "dart",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Directories to exclude from scanning
|
||||||
|
var excludedDirs = map[string]bool{
|
||||||
|
".git": true,
|
||||||
|
"node_modules": true,
|
||||||
|
"vendor": true,
|
||||||
|
"dist": true,
|
||||||
|
"build": true,
|
||||||
|
"target": true,
|
||||||
|
".idea": true,
|
||||||
|
".vscode": true,
|
||||||
|
".github": true,
|
||||||
|
".gitlab": true,
|
||||||
|
"__pycache__": true,
|
||||||
|
".next": true,
|
||||||
|
".nuxt": true,
|
||||||
|
"venv": true,
|
||||||
|
"env": true,
|
||||||
|
".env": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetectLanguages scans a directory to identify programming languages used in the project
|
||||||
|
func DetectLanguages(rootDir string) (map[string]LanguageInfo, error) {
|
||||||
|
languages := make(map[string]LanguageInfo)
|
||||||
|
var mutex sync.Mutex
|
||||||
|
|
||||||
|
// Walk the directory tree
|
||||||
|
err := filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil // Skip files that can't be accessed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip excluded directories
|
||||||
|
if info.IsDir() {
|
||||||
|
if excludedDirs[info.Name()] || strings.HasPrefix(info.Name(), ".") {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip hidden files
|
||||||
|
if strings.HasPrefix(info.Name(), ".") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for project files
|
||||||
|
for _, pattern := range projectFilePatterns {
|
||||||
|
if info.Name() == pattern.Name {
|
||||||
|
mutex.Lock()
|
||||||
|
lang, exists := languages[pattern.LanguageID]
|
||||||
|
if !exists {
|
||||||
|
lang = LanguageInfo{
|
||||||
|
ID: pattern.LanguageID,
|
||||||
|
FileCount: 0,
|
||||||
|
ProjectFiles: []string{},
|
||||||
|
IsPrimary: pattern.IsPrimary,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lang.ProjectFiles = append(lang.ProjectFiles, path)
|
||||||
|
if pattern.IsPrimary {
|
||||||
|
lang.IsPrimary = true
|
||||||
|
}
|
||||||
|
languages[pattern.LanguageID] = lang
|
||||||
|
mutex.Unlock()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file extension
|
||||||
|
ext := strings.ToLower(filepath.Ext(path))
|
||||||
|
if langID, ok := extensionToLanguage[ext]; ok {
|
||||||
|
mutex.Lock()
|
||||||
|
lang, exists := languages[langID]
|
||||||
|
if !exists {
|
||||||
|
lang = LanguageInfo{
|
||||||
|
ID: langID,
|
||||||
|
FileCount: 0,
|
||||||
|
ProjectFiles: []string{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lang.FileCount++
|
||||||
|
languages[langID] = lang
|
||||||
|
mutex.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine primary languages based on file count if not already marked
|
||||||
|
determinePrimaryLanguages(languages)
|
||||||
|
|
||||||
|
// Log detected languages
|
||||||
|
for id, info := range languages {
|
||||||
|
if info.IsPrimary {
|
||||||
|
logging.Debug("Detected primary language", "language", id, "files", info.FileCount, "projectFiles", len(info.ProjectFiles))
|
||||||
|
} else {
|
||||||
|
logging.Debug("Detected secondary language", "language", id, "files", info.FileCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return languages, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// determinePrimaryLanguages marks languages as primary based on file count
|
||||||
|
func determinePrimaryLanguages(languages map[string]LanguageInfo) {
|
||||||
|
// Find the language with the most files
|
||||||
|
var maxFiles int
|
||||||
|
for _, info := range languages {
|
||||||
|
if info.FileCount > maxFiles {
|
||||||
|
maxFiles = info.FileCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark languages with at least 20% of the max files as primary
|
||||||
|
threshold := max(maxFiles/5, 5) // At least 5 files to be considered primary
|
||||||
|
|
||||||
|
for id, info := range languages {
|
||||||
|
if !info.IsPrimary && info.FileCount >= threshold {
|
||||||
|
info.IsPrimary = true
|
||||||
|
languages[id] = info
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLanguageIDFromExtension returns the language ID for a given file extension
|
||||||
|
func GetLanguageIDFromExtension(ext string) string {
|
||||||
|
ext = strings.ToLower(ext)
|
||||||
|
if langID, ok := extensionToLanguage[ext]; ok {
|
||||||
|
return langID
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLanguageIDFromProtocol converts a protocol.LanguageKind to our language ID string
|
||||||
|
func GetLanguageIDFromProtocol(langKind string) string {
|
||||||
|
// Convert protocol language kind to our language ID
|
||||||
|
switch langKind {
|
||||||
|
case "go":
|
||||||
|
return "go"
|
||||||
|
case "typescript":
|
||||||
|
return "typescript"
|
||||||
|
case "typescriptreact":
|
||||||
|
return "typescript"
|
||||||
|
case "javascript":
|
||||||
|
return "javascript"
|
||||||
|
case "javascriptreact":
|
||||||
|
return "javascript"
|
||||||
|
case "python":
|
||||||
|
return "python"
|
||||||
|
case "rust":
|
||||||
|
return "rust"
|
||||||
|
case "java":
|
||||||
|
return "java"
|
||||||
|
case "c":
|
||||||
|
return "c"
|
||||||
|
case "cpp":
|
||||||
|
return "cpp"
|
||||||
|
default:
|
||||||
|
// Try to normalize the language kind
|
||||||
|
return strings.ToLower(langKind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLanguageIDFromPath determines the language ID from a file path
|
||||||
|
func GetLanguageIDFromPath(path string) string {
|
||||||
|
// Check file extension first
|
||||||
|
ext := filepath.Ext(path)
|
||||||
|
if langID := GetLanguageIDFromExtension(ext); langID != "" {
|
||||||
|
return langID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a known project file
|
||||||
|
filename := filepath.Base(path)
|
||||||
|
for _, pattern := range projectFilePatterns {
|
||||||
|
if filename == pattern.Name {
|
||||||
|
return pattern.LanguageID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use LSP's detection as a fallback
|
||||||
|
uri := "file://" + path
|
||||||
|
langKind := lsp.DetectLanguageID(uri)
|
||||||
|
return GetLanguageIDFromProtocol(string(langKind))
|
||||||
|
}
|
||||||
306
internal/lsp/discovery/server.go
Normal file
306
internal/lsp/discovery/server.go
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
package discovery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/opencode-ai/opencode/internal/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ServerInfo contains information about an LSP server
|
||||||
|
type ServerInfo struct {
|
||||||
|
// Command to run the server
|
||||||
|
Command string
|
||||||
|
|
||||||
|
// Arguments to pass to the command
|
||||||
|
Args []string
|
||||||
|
|
||||||
|
// Command to install the server (for user guidance)
|
||||||
|
InstallCmd string
|
||||||
|
|
||||||
|
// Whether this server is available
|
||||||
|
Available bool
|
||||||
|
|
||||||
|
// Full path to the executable (if found)
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
|
// LanguageServerMap maps language IDs to their corresponding LSP servers
|
||||||
|
var LanguageServerMap = map[string]ServerInfo{
|
||||||
|
"go": {
|
||||||
|
Command: "gopls",
|
||||||
|
InstallCmd: "go install golang.org/x/tools/gopls@latest",
|
||||||
|
},
|
||||||
|
"typescript": {
|
||||||
|
Command: "typescript-language-server",
|
||||||
|
Args: []string{"--stdio"},
|
||||||
|
InstallCmd: "npm install -g typescript-language-server typescript",
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
Command: "typescript-language-server",
|
||||||
|
Args: []string{"--stdio"},
|
||||||
|
InstallCmd: "npm install -g typescript-language-server typescript",
|
||||||
|
},
|
||||||
|
"python": {
|
||||||
|
Command: "pylsp",
|
||||||
|
InstallCmd: "pip install python-lsp-server",
|
||||||
|
},
|
||||||
|
"rust": {
|
||||||
|
Command: "rust-analyzer",
|
||||||
|
InstallCmd: "rustup component add rust-analyzer",
|
||||||
|
},
|
||||||
|
"java": {
|
||||||
|
Command: "jdtls",
|
||||||
|
InstallCmd: "Install Eclipse JDT Language Server",
|
||||||
|
},
|
||||||
|
"c": {
|
||||||
|
Command: "clangd",
|
||||||
|
InstallCmd: "Install clangd from your package manager",
|
||||||
|
},
|
||||||
|
"cpp": {
|
||||||
|
Command: "clangd",
|
||||||
|
InstallCmd: "Install clangd from your package manager",
|
||||||
|
},
|
||||||
|
"php": {
|
||||||
|
Command: "intelephense",
|
||||||
|
Args: []string{"--stdio"},
|
||||||
|
InstallCmd: "npm install -g intelephense",
|
||||||
|
},
|
||||||
|
"ruby": {
|
||||||
|
Command: "solargraph",
|
||||||
|
Args: []string{"stdio"},
|
||||||
|
InstallCmd: "gem install solargraph",
|
||||||
|
},
|
||||||
|
"lua": {
|
||||||
|
Command: "lua-language-server",
|
||||||
|
InstallCmd: "Install lua-language-server from your package manager",
|
||||||
|
},
|
||||||
|
"html": {
|
||||||
|
Command: "vscode-html-language-server",
|
||||||
|
Args: []string{"--stdio"},
|
||||||
|
InstallCmd: "npm install -g vscode-langservers-extracted",
|
||||||
|
},
|
||||||
|
"css": {
|
||||||
|
Command: "vscode-css-language-server",
|
||||||
|
Args: []string{"--stdio"},
|
||||||
|
InstallCmd: "npm install -g vscode-langservers-extracted",
|
||||||
|
},
|
||||||
|
"json": {
|
||||||
|
Command: "vscode-json-language-server",
|
||||||
|
Args: []string{"--stdio"},
|
||||||
|
InstallCmd: "npm install -g vscode-langservers-extracted",
|
||||||
|
},
|
||||||
|
"yaml": {
|
||||||
|
Command: "yaml-language-server",
|
||||||
|
Args: []string{"--stdio"},
|
||||||
|
InstallCmd: "npm install -g yaml-language-server",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindLSPServer searches for an LSP server for the given language
|
||||||
|
func FindLSPServer(languageID string) (ServerInfo, error) {
|
||||||
|
// Get server info for the language
|
||||||
|
serverInfo, exists := LanguageServerMap[languageID]
|
||||||
|
if !exists {
|
||||||
|
return ServerInfo{}, fmt.Errorf("no LSP server defined for language: %s", languageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the command is in PATH
|
||||||
|
path, err := exec.LookPath(serverInfo.Command)
|
||||||
|
if err == nil {
|
||||||
|
serverInfo.Available = true
|
||||||
|
serverInfo.Path = path
|
||||||
|
logging.Debug("Found LSP server in PATH", "language", languageID, "command", serverInfo.Command, "path", path)
|
||||||
|
return serverInfo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not in PATH, search in common installation locations
|
||||||
|
paths := getCommonLSPPaths(languageID, serverInfo.Command)
|
||||||
|
for _, searchPath := range paths {
|
||||||
|
if _, err := os.Stat(searchPath); err == nil {
|
||||||
|
// Found the server
|
||||||
|
serverInfo.Available = true
|
||||||
|
serverInfo.Path = searchPath
|
||||||
|
logging.Debug("Found LSP server in common location", "language", languageID, "command", serverInfo.Command, "path", searchPath)
|
||||||
|
return serverInfo, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server not found
|
||||||
|
logging.Debug("LSP server not found", "language", languageID, "command", serverInfo.Command)
|
||||||
|
return serverInfo, fmt.Errorf("LSP server for %s not found. Install with: %s", languageID, serverInfo.InstallCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getCommonLSPPaths returns common installation paths for LSP servers based on language and OS
|
||||||
|
func getCommonLSPPaths(languageID, command string) []string {
|
||||||
|
var paths []string
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
logging.Error("Failed to get user home directory", "error", err)
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add platform-specific paths
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "darwin":
|
||||||
|
// macOS paths
|
||||||
|
paths = append(paths,
|
||||||
|
fmt.Sprintf("/usr/local/bin/%s", command),
|
||||||
|
fmt.Sprintf("/opt/homebrew/bin/%s", command),
|
||||||
|
fmt.Sprintf("%s/.local/bin/%s", homeDir, command),
|
||||||
|
)
|
||||||
|
case "linux":
|
||||||
|
// Linux paths
|
||||||
|
paths = append(paths,
|
||||||
|
fmt.Sprintf("/usr/bin/%s", command),
|
||||||
|
fmt.Sprintf("/usr/local/bin/%s", command),
|
||||||
|
fmt.Sprintf("%s/.local/bin/%s", homeDir, command),
|
||||||
|
)
|
||||||
|
case "windows":
|
||||||
|
// Windows paths
|
||||||
|
paths = append(paths,
|
||||||
|
fmt.Sprintf("%s\\AppData\\Local\\Programs\\%s.exe", homeDir, command),
|
||||||
|
fmt.Sprintf("C:\\Program Files\\%s\\bin\\%s.exe", command, command),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add language-specific paths
|
||||||
|
switch languageID {
|
||||||
|
case "go":
|
||||||
|
gopath := os.Getenv("GOPATH")
|
||||||
|
if gopath == "" {
|
||||||
|
gopath = filepath.Join(homeDir, "go")
|
||||||
|
}
|
||||||
|
paths = append(paths, filepath.Join(gopath, "bin", command))
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
paths = append(paths, filepath.Join(gopath, "bin", command+".exe"))
|
||||||
|
}
|
||||||
|
case "typescript", "javascript", "html", "css", "json", "yaml", "php":
|
||||||
|
// Node.js global packages
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
paths = append(paths,
|
||||||
|
fmt.Sprintf("%s\\AppData\\Roaming\\npm\\%s.cmd", homeDir, command),
|
||||||
|
fmt.Sprintf("%s\\AppData\\Roaming\\npm\\node_modules\\.bin\\%s.cmd", homeDir, command),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
paths = append(paths,
|
||||||
|
fmt.Sprintf("%s/.npm-global/bin/%s", homeDir, command),
|
||||||
|
fmt.Sprintf("%s/.nvm/versions/node/*/bin/%s", homeDir, command),
|
||||||
|
fmt.Sprintf("/usr/local/lib/node_modules/.bin/%s", command),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case "python":
|
||||||
|
// Python paths
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
paths = append(paths,
|
||||||
|
fmt.Sprintf("%s\\AppData\\Local\\Programs\\Python\\Python*\\Scripts\\%s.exe", homeDir, command),
|
||||||
|
fmt.Sprintf("C:\\Python*\\Scripts\\%s.exe", command),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
paths = append(paths,
|
||||||
|
fmt.Sprintf("%s/.local/bin/%s", homeDir, command),
|
||||||
|
fmt.Sprintf("%s/.pyenv/shims/%s", homeDir, command),
|
||||||
|
fmt.Sprintf("/usr/local/bin/%s", command),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case "rust":
|
||||||
|
// Rust paths
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
paths = append(paths,
|
||||||
|
fmt.Sprintf("%s\\.rustup\\toolchains\\*\\bin\\%s.exe", homeDir, command),
|
||||||
|
fmt.Sprintf("%s\\.cargo\\bin\\%s.exe", homeDir, command),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
paths = append(paths,
|
||||||
|
fmt.Sprintf("%s/.rustup/toolchains/*/bin/%s", homeDir, command),
|
||||||
|
fmt.Sprintf("%s/.cargo/bin/%s", homeDir, command),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add VSCode extensions path
|
||||||
|
vscodePath := getVSCodeExtensionsPath(homeDir)
|
||||||
|
if vscodePath != "" {
|
||||||
|
paths = append(paths, vscodePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand any glob patterns in paths
|
||||||
|
var expandedPaths []string
|
||||||
|
for _, path := range paths {
|
||||||
|
if strings.Contains(path, "*") {
|
||||||
|
// This is a glob pattern, expand it
|
||||||
|
matches, err := filepath.Glob(path)
|
||||||
|
if err == nil {
|
||||||
|
expandedPaths = append(expandedPaths, matches...)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
expandedPaths = append(expandedPaths, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return expandedPaths
|
||||||
|
}
|
||||||
|
|
||||||
|
// getVSCodeExtensionsPath returns the path to VSCode extensions directory
|
||||||
|
func getVSCodeExtensionsPath(homeDir string) string {
|
||||||
|
var basePath string
|
||||||
|
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "darwin":
|
||||||
|
basePath = filepath.Join(homeDir, "Library", "Application Support", "Code", "User", "globalStorage")
|
||||||
|
case "linux":
|
||||||
|
basePath = filepath.Join(homeDir, ".config", "Code", "User", "globalStorage")
|
||||||
|
case "windows":
|
||||||
|
basePath = filepath.Join(homeDir, "AppData", "Roaming", "Code", "User", "globalStorage")
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the directory exists
|
||||||
|
if _, err := os.Stat(basePath); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return basePath
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigureLSPServers detects languages and configures LSP servers
|
||||||
|
func ConfigureLSPServers(rootDir string) (map[string]ServerInfo, error) {
|
||||||
|
// Detect languages in the project
|
||||||
|
languages, err := DetectLanguages(rootDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to detect languages: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find LSP servers for detected languages
|
||||||
|
servers := make(map[string]ServerInfo)
|
||||||
|
for langID, langInfo := range languages {
|
||||||
|
// Prioritize primary languages but include all languages that have server definitions
|
||||||
|
if !langInfo.IsPrimary && langInfo.FileCount < 3 {
|
||||||
|
// Skip non-primary languages with very few files
|
||||||
|
logging.Debug("Skipping non-primary language with few files", "language", langID, "files", langInfo.FileCount)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we have a server for this language
|
||||||
|
serverInfo, err := FindLSPServer(langID)
|
||||||
|
if err != nil {
|
||||||
|
logging.Warn("LSP server not found", "language", langID, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to the map of configured servers
|
||||||
|
servers[langID] = serverInfo
|
||||||
|
if langInfo.IsPrimary {
|
||||||
|
logging.Info("Configured LSP server for primary language", "language", langID, "command", serverInfo.Command, "path", serverInfo.Path)
|
||||||
|
} else {
|
||||||
|
logging.Info("Configured LSP server for secondary language", "language", langID, "command", serverInfo.Command, "path", serverInfo.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return servers, nil
|
||||||
|
}
|
||||||
92
internal/lsp/discovery/tool/lsp_tool.go
Normal file
92
internal/lsp/discovery/tool/lsp_tool.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package tool
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/opencode-ai/opencode/internal/config"
|
||||||
|
"github.com/opencode-ai/opencode/internal/logging"
|
||||||
|
"github.com/opencode-ai/opencode/internal/lsp/discovery"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConfigureLspServerRequest represents the request for the configureLspServer tool
|
||||||
|
type ConfigureLspServerRequest struct {
|
||||||
|
// Language identifier (e.g., "go", "typescript", "python")
|
||||||
|
Language string `json:"language"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigureLspServerResponse represents the response from the configureLspServer tool
|
||||||
|
type ConfigureLspServerResponse struct {
|
||||||
|
// Whether the server was found
|
||||||
|
Found bool `json:"found"`
|
||||||
|
|
||||||
|
// Path to the server executable
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
|
|
||||||
|
// Command to run the server
|
||||||
|
Command string `json:"command,omitempty"`
|
||||||
|
|
||||||
|
// Arguments to pass to the command
|
||||||
|
Args []string `json:"args,omitempty"`
|
||||||
|
|
||||||
|
// Installation instructions if the server was not found
|
||||||
|
InstallInstructions string `json:"installInstructions,omitempty"`
|
||||||
|
|
||||||
|
// Whether the server was added to the configuration
|
||||||
|
Added bool `json:"added,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigureLspServer searches for an LSP server for the given language
|
||||||
|
func ConfigureLspServer(ctx context.Context, rawArgs json.RawMessage) (any, error) {
|
||||||
|
var args ConfigureLspServerRequest
|
||||||
|
if err := json.Unmarshal(rawArgs, &args); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse arguments: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.Language == "" {
|
||||||
|
return nil, fmt.Errorf("language parameter is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the LSP server for the language
|
||||||
|
serverInfo, err := discovery.FindLSPServer(args.Language)
|
||||||
|
if err != nil {
|
||||||
|
// Server not found, return instructions
|
||||||
|
return ConfigureLspServerResponse{
|
||||||
|
Found: false,
|
||||||
|
Command: serverInfo.Command,
|
||||||
|
Args: serverInfo.Args,
|
||||||
|
InstallInstructions: serverInfo.InstallCmd,
|
||||||
|
Added: false,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server found, update the configuration if available
|
||||||
|
added := false
|
||||||
|
if serverInfo.Available {
|
||||||
|
// Get the current configuration
|
||||||
|
cfg := config.Get()
|
||||||
|
if cfg != nil {
|
||||||
|
// Add the server to the configuration
|
||||||
|
cfg.LSP[args.Language] = config.LSPConfig{
|
||||||
|
Disabled: false,
|
||||||
|
Command: serverInfo.Path,
|
||||||
|
Args: serverInfo.Args,
|
||||||
|
}
|
||||||
|
added = true
|
||||||
|
logging.Info("Added LSP server to configuration",
|
||||||
|
"language", args.Language,
|
||||||
|
"command", serverInfo.Command,
|
||||||
|
"path", serverInfo.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the server information
|
||||||
|
return ConfigureLspServerResponse{
|
||||||
|
Found: true,
|
||||||
|
Path: serverInfo.Path,
|
||||||
|
Command: serverInfo.Command,
|
||||||
|
Args: serverInfo.Args,
|
||||||
|
Added: added,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -56,8 +56,8 @@ var keys = keyMap{
|
|||||||
),
|
),
|
||||||
|
|
||||||
Models: key.NewBinding(
|
Models: key.NewBinding(
|
||||||
key.WithKeys("ctrl+m"),
|
key.WithKeys("ctrl+o"),
|
||||||
key.WithHelp("ctrl+m", "model selection"),
|
key.WithHelp("ctrl+o", "model selection"),
|
||||||
),
|
),
|
||||||
|
|
||||||
SwitchTheme: key.NewBinding(
|
SwitchTheme: key.NewBinding(
|
||||||
@@ -385,10 +385,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return a, nil
|
return a, nil
|
||||||
case key.Matches(msg, keys.SwitchTheme):
|
case key.Matches(msg, keys.SwitchTheme):
|
||||||
if !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog {
|
if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog {
|
||||||
// Show theme switcher dialog
|
|
||||||
a.showThemeDialog = true
|
a.showThemeDialog = true
|
||||||
// Theme list is dynamically loaded by the dialog component
|
|
||||||
return a, a.themeDialog.Init()
|
return a, a.themeDialog.Init()
|
||||||
}
|
}
|
||||||
return a, nil
|
return a, nil
|
||||||
|
|||||||
Reference in New Issue
Block a user