From 90097e9adb207608cfe94c45d9744c87fc49d02a Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 14 Aug 2025 14:09:39 -0300 Subject: [PATCH] feat(lsp): add filetypes configuration (#666) * feat(lsp): add filetypes configuration Signed-off-by: Carlos Alexandro Becker * fix: simplify Signed-off-by: Carlos Alexandro Becker * fix: test Signed-off-by: Carlos Alexandro Becker * refactor: cleanup Signed-off-by: Carlos Alexandro Becker * fix: improvements Signed-off-by: Carlos Alexandro Becker * fix: accept fts and exts Signed-off-by: Carlos Alexandro Becker --------- Signed-off-by: Carlos Alexandro Becker --- 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(-) create mode 100644 internal/lsp/client_test.go diff --git a/internal/app/lsp.go b/internal/app/lsp.go index e5b16d3c5e8efb4f7569e426bda6e30dceb127c5..b1f35dedc02c9ae842a8e0d2d52b51eaf38bd2ee 100644 --- a/internal/app/lsp.go +++ b/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) } diff --git a/internal/config/config.go b/internal/config/config.go index f4ba8848033a9e99b8d487e5a494565c89bda5a0..d24f178a9023d9a627a9c271c712a4db75e8706c 100644 --- a/internal/config/config.go +++ b/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 { diff --git a/internal/config/load.go b/internal/config/load.go index e2dfcdbbf9dbd60dd5afc007338bad0e3e410050..12defe528334dba0a0c93463310ffbd3d9226a56 100644 --- a/internal/config/load.go +++ b/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") diff --git a/internal/lsp/client.go b/internal/lsp/client.go index 1dbdd32f543db4b0b6b37df49de7c513de128b45..32346fcb6ca95e8da292de81188c14cb3ce2f1f6 100644 --- a/internal/lsp/client.go +++ b/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() diff --git a/internal/lsp/client_test.go b/internal/lsp/client_test.go new file mode 100644 index 0000000000000000000000000000000000000000..f97b9bdddba1e0fa5ab22cbe68635b4f1b9b02c3 --- /dev/null +++ b/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) + }) + } +} diff --git a/internal/lsp/watcher/watcher.go b/internal/lsp/watcher/watcher.go index 6173d6e18e046345cc097052f6a06ff44b3e1e61..476c49361e2ba4e07b6c9b64a8d884e74d3013ed 100644 --- a/internal/lsp/watcher/watcher.go +++ b/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 diff --git a/schema.json b/schema.json index 4c2066505f01270fa94eaa5ad70dfbe4f8442728..9d82094718298a4c90c4e7e224385b62830303fe 100644 --- a/schema.json +++ b/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,