feat(lsp): add filetypes configuration (#666)

Carlos Alexandro Becker created

* feat(lsp): add filetypes configuration

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fix: simplify

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fix: test

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* refactor: cleanup

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fix: improvements

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fix: accept fts and exts

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

---------

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

Change summary

internal/app/lsp.go             | 11 ++-
internal/config/config.go       |  9 +-
internal/config/load.go         | 35 +++++++++++++
internal/lsp/client.go          | 36 ++++++++++++
internal/lsp/client_test.go     | 93 +++++++++++++++++++++++++++++++++++
internal/lsp/watcher/watcher.go | 18 +++---
schema.json                     | 11 ++++
7 files changed, 194 insertions(+), 19 deletions(-)

Detailed changes

internal/app/lsp.go 🔗

@@ -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)
 }

internal/config/config.go 🔗

@@ -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 {

internal/config/load.go 🔗

@@ -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")

internal/lsp/client.go 🔗

@@ -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()

internal/lsp/client_test.go 🔗

@@ -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)
+		})
+	}
+}

internal/lsp/watcher/watcher.go 🔗

@@ -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

schema.json 🔗

@@ -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,