Detailed changes
@@ -5,6 +5,7 @@ import (
"log/slog"
"time"
+ "github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/log"
"github.com/charmbracelet/crush/internal/lsp"
"github.com/charmbracelet/crush/internal/lsp/watcher"
@@ -13,20 +14,20 @@ import (
// initLSPClients initializes LSP clients.
func (app *App) initLSPClients(ctx context.Context) {
for name, clientConfig := range app.config.LSP {
- go app.createAndStartLSPClient(ctx, name, clientConfig.Command, clientConfig.Args...)
+ go app.createAndStartLSPClient(ctx, name, clientConfig)
}
slog.Info("LSP clients initialization started in background")
}
// createAndStartLSPClient creates a new LSP client, initializes it, and starts its workspace watcher
-func (app *App) createAndStartLSPClient(ctx context.Context, name string, command string, args ...string) {
- slog.Info("Creating LSP client", "name", name, "command", command, "args", args)
+func (app *App) createAndStartLSPClient(ctx context.Context, name string, config config.LSPConfig) {
+ slog.Info("Creating LSP client", "name", name, "command", config.Command, "fileTypes", config.FileTypes, "args", config.Args)
// Update state to starting
updateLSPState(name, lsp.StateStarting, nil, nil, 0)
// Create LSP client.
- lspClient, err := lsp.NewClient(ctx, name, command, args...)
+ lspClient, err := lsp.NewClient(ctx, name, config)
if err != nil {
slog.Error("Failed to create LSP client for", name, err)
updateLSPState(name, lsp.StateError, err, nil, 0)
@@ -123,6 +124,6 @@ func (app *App) restartLSPClient(ctx context.Context, name string) {
}
// Create a new client using the shared function.
- app.createAndStartLSPClient(ctx, name, clientConfig.Command, clientConfig.Args...)
+ app.createAndStartLSPClient(ctx, name, clientConfig)
slog.Info("Successfully restarted LSP client", "client", name)
}
@@ -117,10 +117,11 @@ type MCPConfig struct {
}
type LSPConfig struct {
- Disabled bool `json:"enabled,omitempty" jsonschema:"description=Whether this LSP server is disabled,default=false"`
- Command string `json:"command" jsonschema:"required,description=Command to execute for the LSP server,example=gopls"`
- Args []string `json:"args,omitempty" jsonschema:"description=Arguments to pass to the LSP server command"`
- Options any `json:"options,omitempty" jsonschema:"description=LSP server-specific configuration options"`
+ Disabled bool `json:"enabled,omitempty" jsonschema:"description=Whether this LSP server is disabled,default=false"`
+ Command string `json:"command" jsonschema:"required,description=Command to execute for the LSP server,example=gopls"`
+ Args []string `json:"args,omitempty" jsonschema:"description=Arguments to pass to the LSP server command"`
+ Options any `json:"options,omitempty" jsonschema:"description=LSP server-specific configuration options"`
+ FileTypes []string `json:"filetypes,omitempty" jsonschema:"description=File types this LSP server handles,example=go,example=mod,example=rs,example=c,example=js,example=ts"`
}
type TUIOptions struct {
@@ -328,12 +328,47 @@ func (c *Config) setDefaults(workingDir string) {
c.LSP = make(map[string]LSPConfig)
}
+ // Apply default file types for known LSP servers if not specified
+ applyDefaultLSPFileTypes(c.LSP)
+
// Add the default context paths if they are not already present
c.Options.ContextPaths = append(defaultContextPaths, c.Options.ContextPaths...)
slices.Sort(c.Options.ContextPaths)
c.Options.ContextPaths = slices.Compact(c.Options.ContextPaths)
}
+var defaultLSPFileTypes = map[string][]string{
+ "gopls": {"go", "mod", "sum", "work"},
+ "typescript-language-server": {"ts", "tsx", "js", "jsx", "mjs", "cjs"},
+ "vtsls": {"ts", "tsx", "js", "jsx", "mjs", "cjs"},
+ "bash-language-server": {"sh", "bash", "zsh", "ksh"},
+ "rust-analyzer": {"rs"},
+ "pyright": {"py", "pyi"},
+ "pylsp": {"py", "pyi"},
+ "clangd": {"c", "cpp", "cc", "cxx", "h", "hpp"},
+ "jdtls": {"java"},
+ "vscode-html-languageserver": {"html", "htm"},
+ "vscode-css-languageserver": {"css", "scss", "sass", "less"},
+ "vscode-json-languageserver": {"json", "jsonc"},
+ "yaml-language-server": {"yaml", "yml"},
+ "lua-language-server": {"lua"},
+ "solargraph": {"rb"},
+ "elixir-ls": {"ex", "exs"},
+ "zls": {"zig"},
+}
+
+// applyDefaultLSPFileTypes sets default file types for known LSP servers
+func applyDefaultLSPFileTypes(lspConfigs map[string]LSPConfig) {
+ for name, config := range lspConfigs {
+ if len(config.FileTypes) != 0 {
+ continue
+ }
+ bin := strings.ToLower(filepath.Base(config.Command))
+ config.FileTypes = defaultLSPFileTypes[bin]
+ lspConfigs[name] = config
+ }
+}
+
func (c *Config) defaultModelSelection(knownProviders []catwalk.Provider) (largeModel SelectedModel, smallModel SelectedModel, err error) {
if len(knownProviders) == 0 && c.Providers.Len() == 0 {
err = fmt.Errorf("no providers configured, please configure at least one provider")
@@ -30,6 +30,9 @@ type Client struct {
// Client name for identification
name string
+ // File types this LSP server handles (e.g., .go, .rs, .py)
+ fileTypes []string
+
// Diagnostic change callback
onDiagnosticsChanged func(name string, count int)
@@ -60,8 +63,9 @@ type Client struct {
serverState atomic.Value
}
-func NewClient(ctx context.Context, name, command string, args ...string) (*Client, error) {
- cmd := exec.CommandContext(ctx, command, args...)
+// NewClient creates a new LSP client.
+func NewClient(ctx context.Context, name string, config config.LSPConfig) (*Client, error) {
+ cmd := exec.CommandContext(ctx, config.Command, config.Args...)
// Copy env
cmd.Env = os.Environ()
@@ -83,6 +87,7 @@ func NewClient(ctx context.Context, name, command string, args ...string) (*Clie
client := &Client{
Cmd: cmd,
name: name,
+ fileTypes: config.FileTypes,
stdin: stdin,
stdout: bufio.NewReader(stdout),
stderr: stderr,
@@ -609,7 +614,34 @@ type OpenFileInfo struct {
URI protocol.DocumentURI
}
+// HandlesFile checks if this LSP client handles the given file based on its
+// extension.
+func (c *Client) HandlesFile(path string) bool {
+ // If no file types are specified, handle all files (backward compatibility)
+ if len(c.fileTypes) == 0 {
+ return true
+ }
+
+ name := strings.ToLower(filepath.Base(path))
+ for _, filetpe := range c.fileTypes {
+ suffix := strings.ToLower(filetpe)
+ if !strings.HasPrefix(suffix, ".") {
+ suffix = "." + suffix
+ }
+ if strings.HasSuffix(name, suffix) {
+ slog.Debug("handles file", "name", c.name, "file", name, "filetype", filetpe)
+ return true
+ }
+ }
+ slog.Debug("doesn't handle file", "name", c.name, "file", name)
+ return false
+}
+
func (c *Client) OpenFile(ctx context.Context, filepath string) error {
+ if !c.HandlesFile(filepath) {
+ return nil
+ }
+
uri := string(protocol.URIFromPath(filepath))
c.openFilesMu.Lock()
@@ -0,0 +1,93 @@
+package lsp
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestHandlesFile(t *testing.T) {
+ tests := []struct {
+ name string
+ fileTypes []string
+ filepath string
+ expected bool
+ }{
+ {
+ name: "no file types specified - handles all files",
+ fileTypes: nil,
+ filepath: "test.go",
+ expected: true,
+ },
+ {
+ name: "empty file types - handles all files",
+ fileTypes: []string{},
+ filepath: "test.go",
+ expected: true,
+ },
+ {
+ name: "matches .go extension",
+ fileTypes: []string{".go"},
+ filepath: "main.go",
+ expected: true,
+ },
+ {
+ name: "matches go extension without dot",
+ fileTypes: []string{"go"},
+ filepath: "main.go",
+ expected: true,
+ },
+ {
+ name: "matches one of multiple extensions",
+ fileTypes: []string{".js", ".ts", ".tsx"},
+ filepath: "component.tsx",
+ expected: true,
+ },
+ {
+ name: "does not match extension",
+ fileTypes: []string{".go", ".rs"},
+ filepath: "script.sh",
+ expected: false,
+ },
+ {
+ name: "matches with full path",
+ fileTypes: []string{".sh"},
+ filepath: "/usr/local/bin/script.sh",
+ expected: true,
+ },
+ {
+ name: "case insensitive matching",
+ fileTypes: []string{".GO"},
+ filepath: "main.go",
+ expected: true,
+ },
+ {
+ name: "bash file types",
+ fileTypes: []string{".sh", ".bash", ".zsh", ".ksh"},
+ filepath: "script.sh",
+ expected: true,
+ },
+ {
+ name: "bash should not handle go files",
+ fileTypes: []string{".sh", ".bash", ".zsh", ".ksh"},
+ filepath: "main.go",
+ expected: false,
+ },
+ {
+ name: "bash should not handle json files",
+ fileTypes: []string{".sh", ".bash", ".zsh", ".ksh"},
+ filepath: "config.json",
+ expected: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ client := &Client{
+ fileTypes: tt.fileTypes,
+ }
+ result := client.HandlesFile(tt.filepath)
+ require.Equal(t, tt.expected, result)
+ })
+ }
+}
@@ -387,6 +387,10 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
return
}
+ if !w.client.HandlesFile(event.Name) {
+ continue // client doesn't handle this filetype
+ }
+
uri := string(protocol.URIFromPath(event.Name))
// Add new directories to the watcher
@@ -431,8 +435,11 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
// Just send the notification if needed
info, err := os.Stat(event.Name)
if err != nil {
- slog.Error("Error getting file info", "path", event.Name, "error", err)
- return
+ if !os.IsNotExist(err) {
+ // Only log if it's not a "file not found" error
+ slog.Debug("Error getting file info", "path", event.Name, "error", err)
+ }
+ continue
}
if !info.IsDir() && watchKind&protocol.WatchCreate != 0 {
w.debounceHandleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Created))
@@ -801,6 +808,7 @@ func shouldExcludeDir(dirPath string) bool {
func shouldExcludeFile(filePath string) bool {
fileName := filepath.Base(filePath)
cfg := config.Get()
+
// Skip dot files
if strings.HasPrefix(fileName, ".") {
return true
@@ -812,12 +820,6 @@ func shouldExcludeFile(filePath string) bool {
return true
}
- // Skip temporary files
- if strings.HasSuffix(filePath, "~") {
- return true
- }
-
- // Check file size
info, err := os.Stat(filePath)
if err != nil {
// If we can't stat the file, skip it
@@ -65,6 +65,17 @@
},
"options": {
"description": "LSP server-specific configuration options"
+ },
+ "filetypes": {
+ "items": {
+ "type": "string",
+ "examples": [
+ ".go",
+ ".mod"
+ ]
+ },
+ "type": "array",
+ "description": "File extensions this LSP server handles (e.g. .go .rs .py)"
}
},
"additionalProperties": false,