on main help sign in
feat(lsp): add filetypes configuration (#666)
90097e9Carlos Alexandro Becker
internal/app/lsp.go+6−5
@@ -5,6 +5,7 @@ import (
5 "log/slog"
5 "log/slog"
6 "time"
6 "time"
7
7
8+ "github.com/charmbracelet/crush/internal/config"
8 "github.com/charmbracelet/crush/internal/log"
9 "github.com/charmbracelet/crush/internal/log"
9 "github.com/charmbracelet/crush/internal/lsp"
10 "github.com/charmbracelet/crush/internal/lsp"
10 "github.com/charmbracelet/crush/internal/lsp/watcher"
11 "github.com/charmbracelet/crush/internal/lsp/watcher"
@@ -13,20 +14,20 @@ import (
13// initLSPClients initializes LSP clients.
14// initLSPClients initializes LSP clients.
14func (app *App) initLSPClients(ctx context.Context) {
15func (app *App) initLSPClients(ctx context.Context) {
15 for name, clientConfig := range app.config.LSP {
16 for name, clientConfig := range app.config.LSP {
16− go app.createAndStartLSPClient(ctx, name, clientConfig.Command, clientConfig.Args...)
17+ go app.createAndStartLSPClient(ctx, name, clientConfig)
17 }
18 }
18 slog.Info("LSP clients initialization started in background")
19 slog.Info("LSP clients initialization started in background")
19}
20}
20
21
21// createAndStartLSPClient creates a new LSP client, initializes it, and starts its workspace watcher
22// createAndStartLSPClient creates a new LSP client, initializes it, and starts its workspace watcher
22−func (app *App) createAndStartLSPClient(ctx context.Context, name string, command string, args ...string) {
23+func (app *App) createAndStartLSPClient(ctx context.Context, name string, config config.LSPConfig) {
23− slog.Info("Creating LSP client", "name", name, "command", command, "args", args)
24+ slog.Info("Creating LSP client", "name", name, "command", config.Command, "fileTypes", config.FileTypes, "args", config.Args)
24
25
25 // Update state to starting
26 // Update state to starting
26 updateLSPState(name, lsp.StateStarting, nil, nil, 0)
27 updateLSPState(name, lsp.StateStarting, nil, nil, 0)
27
28
28 // Create LSP client.
29 // Create LSP client.
29− lspClient, err := lsp.NewClient(ctx, name, command, args...)
30+ lspClient, err := lsp.NewClient(ctx, name, config)
30 if err != nil {
31 if err != nil {
31 slog.Error("Failed to create LSP client for", name, err)
32 slog.Error("Failed to create LSP client for", name, err)
32 updateLSPState(name, lsp.StateError, err, nil, 0)
33 updateLSPState(name, lsp.StateError, err, nil, 0)
@@ -123,6 +124,6 @@ func (app *App) restartLSPClient(ctx context.Context, name string) {
123 }
124 }
124
125
125 // Create a new client using the shared function.
126 // Create a new client using the shared function.
126− app.createAndStartLSPClient(ctx, name, clientConfig.Command, clientConfig.Args...)
127+ app.createAndStartLSPClient(ctx, name, clientConfig)
127 slog.Info("Successfully restarted LSP client", "client", name)
128 slog.Info("Successfully restarted LSP client", "client", name)
128}
129}
internal/config/config.go+5−4
@@ -117,10 +117,11 @@ type MCPConfig struct {
117}
117}
118
118
119type LSPConfig struct {
119type LSPConfig struct {
120− Disabled bool `json:"enabled,omitempty" jsonschema:"description=Whether this LSP server is disabled,default=false"`
120+ Disabled bool `json:"enabled,omitempty" jsonschema:"description=Whether this LSP server is disabled,default=false"`
121− Command string `json:"command" jsonschema:"required,description=Command to execute for the LSP server,example=gopls"`
121+ Command string `json:"command" jsonschema:"required,description=Command to execute for the LSP server,example=gopls"`
122− Args []string `json:"args,omitempty" jsonschema:"description=Arguments to pass to the LSP server command"`
122+ Args []string `json:"args,omitempty" jsonschema:"description=Arguments to pass to the LSP server command"`
123− Options any `json:"options,omitempty" jsonschema:"description=LSP server-specific configuration options"`
123+ Options any `json:"options,omitempty" jsonschema:"description=LSP server-specific configuration options"`
124+ 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"`
124}
125}
125
126
126type TUIOptions struct {
127type TUIOptions struct {
internal/config/load.go+35
@@ -328,12 +328,47 @@ func (c *Config) setDefaults(workingDir string) {
328 c.LSP = make(map[string]LSPConfig)
328 c.LSP = make(map[string]LSPConfig)
329 }
329 }
330
330
331+ // Apply default file types for known LSP servers if not specified
332+ applyDefaultLSPFileTypes(c.LSP)
333+
331 // Add the default context paths if they are not already present
334 // Add the default context paths if they are not already present
332 c.Options.ContextPaths = append(defaultContextPaths, c.Options.ContextPaths...)
335 c.Options.ContextPaths = append(defaultContextPaths, c.Options.ContextPaths...)
333 slices.Sort(c.Options.ContextPaths)
336 slices.Sort(c.Options.ContextPaths)
334 c.Options.ContextPaths = slices.Compact(c.Options.ContextPaths)
337 c.Options.ContextPaths = slices.Compact(c.Options.ContextPaths)
335}
338}
336
339
340+var defaultLSPFileTypes = map[string][]string{
341+ "gopls": {"go", "mod", "sum", "work"},
342+ "typescript-language-server": {"ts", "tsx", "js", "jsx", "mjs", "cjs"},
343+ "vtsls": {"ts", "tsx", "js", "jsx", "mjs", "cjs"},
344+ "bash-language-server": {"sh", "bash", "zsh", "ksh"},
345+ "rust-analyzer": {"rs"},
346+ "pyright": {"py", "pyi"},
347+ "pylsp": {"py", "pyi"},
348+ "clangd": {"c", "cpp", "cc", "cxx", "h", "hpp"},
349+ "jdtls": {"java"},
350+ "vscode-html-languageserver": {"html", "htm"},
351+ "vscode-css-languageserver": {"css", "scss", "sass", "less"},
352+ "vscode-json-languageserver": {"json", "jsonc"},
353+ "yaml-language-server": {"yaml", "yml"},
354+ "lua-language-server": {"lua"},
355+ "solargraph": {"rb"},
356+ "elixir-ls": {"ex", "exs"},
357+ "zls": {"zig"},
358+}
359+
360+// applyDefaultLSPFileTypes sets default file types for known LSP servers
361+func applyDefaultLSPFileTypes(lspConfigs map[string]LSPConfig) {
362+ for name, config := range lspConfigs {
363+ if len(config.FileTypes) != 0 {
364+ continue
365+ }
366+ bin := strings.ToLower(filepath.Base(config.Command))
367+ config.FileTypes = defaultLSPFileTypes[bin]
368+ lspConfigs[name] = config
369+ }
370+}
371+
337func (c *Config) defaultModelSelection(knownProviders []catwalk.Provider) (largeModel SelectedModel, smallModel SelectedModel, err error) {
372func (c *Config) defaultModelSelection(knownProviders []catwalk.Provider) (largeModel SelectedModel, smallModel SelectedModel, err error) {
338 if len(knownProviders) == 0 && c.Providers.Len() == 0 {
373 if len(knownProviders) == 0 && c.Providers.Len() == 0 {
339 err = fmt.Errorf("no providers configured, please configure at least one provider")
374 err = fmt.Errorf("no providers configured, please configure at least one provider")
internal/lsp/client.go+34−2
@@ -30,6 +30,9 @@ type Client struct {
30 // Client name for identification
30 // Client name for identification
31 name string
31 name string
32
32
33+ // File types this LSP server handles (e.g., .go, .rs, .py)
34+ fileTypes []string
35+
33 // Diagnostic change callback
36 // Diagnostic change callback
34 onDiagnosticsChanged func(name string, count int)
37 onDiagnosticsChanged func(name string, count int)
35
38
@@ -60,8 +63,9 @@ type Client struct {
60 serverState atomic.Value
63 serverState atomic.Value
61}
64}
62
65
66+// NewClient creates a new LSP client.
63−func NewClient(ctx context.Context, name, command string, args ...string) (*Client, error) {
67+func NewClient(ctx context.Context, name string, config config.LSPConfig) (*Client, error) {
64− cmd := exec.CommandContext(ctx, command, args...)
68+ cmd := exec.CommandContext(ctx, config.Command, config.Args...)
65 // Copy env
69 // Copy env
66 cmd.Env = os.Environ()
70 cmd.Env = os.Environ()
67
71
@@ -83,6 +87,7 @@ func NewClient(ctx context.Context, name, command string, args ...string) (*Clie
83 client := &Client{
87 client := &Client{
84 Cmd: cmd,
88 Cmd: cmd,
85 name: name,
89 name: name,
90+ fileTypes: config.FileTypes,
86 stdin: stdin,
91 stdin: stdin,
87 stdout: bufio.NewReader(stdout),
92 stdout: bufio.NewReader(stdout),
88 stderr: stderr,
93 stderr: stderr,
@@ -609,7 +614,34 @@ type OpenFileInfo struct {
609 URI protocol.DocumentURI
614 URI protocol.DocumentURI
610}
615}
611
616
617+// HandlesFile checks if this LSP client handles the given file based on its
618+// extension.
619+func (c *Client) HandlesFile(path string) bool {
620+ // If no file types are specified, handle all files (backward compatibility)
621+ if len(c.fileTypes) == 0 {
622+ return true
623+ }
624+
625+ name := strings.ToLower(filepath.Base(path))
626+ for _, filetpe := range c.fileTypes {
627+ suffix := strings.ToLower(filetpe)
628+ if !strings.HasPrefix(suffix, ".") {
629+ suffix = "." + suffix
630+ }
631+ if strings.HasSuffix(name, suffix) {
632+ slog.Debug("handles file", "name", c.name, "file", name, "filetype", filetpe)
633+ return true
634+ }
635+ }
636+ slog.Debug("doesn't handle file", "name", c.name, "file", name)
637+ return false
638+}
639+
612func (c *Client) OpenFile(ctx context.Context, filepath string) error {
640func (c *Client) OpenFile(ctx context.Context, filepath string) error {
641+ if !c.HandlesFile(filepath) {
642+ return nil
643+ }
644+
613 uri := string(protocol.URIFromPath(filepath))
645 uri := string(protocol.URIFromPath(filepath))
614
646
615 c.openFilesMu.Lock()
647 c.openFilesMu.Lock()
internal/lsp/client_test.gonew file+93
@@ -0,0 +1,93 @@
1+package lsp
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestHandlesFile(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ fileTypes []string
13+ filepath string
14+ expected bool
15+ }{
16+ {
17+ name: "no file types specified - handles all files",
18+ fileTypes: nil,
19+ filepath: "test.go",
20+ expected: true,
21+ },
22+ {
23+ name: "empty file types - handles all files",
24+ fileTypes: []string{},
25+ filepath: "test.go",
26+ expected: true,
27+ },
28+ {
29+ name: "matches .go extension",
30+ fileTypes: []string{".go"},
31+ filepath: "main.go",
32+ expected: true,
33+ },
34+ {
35+ name: "matches go extension without dot",
36+ fileTypes: []string{"go"},
37+ filepath: "main.go",
38+ expected: true,
39+ },
40+ {
41+ name: "matches one of multiple extensions",
42+ fileTypes: []string{".js", ".ts", ".tsx"},
43+ filepath: "component.tsx",
44+ expected: true,
45+ },
46+ {
47+ name: "does not match extension",
48+ fileTypes: []string{".go", ".rs"},
49+ filepath: "script.sh",
50+ expected: false,
51+ },
52+ {
53+ name: "matches with full path",
54+ fileTypes: []string{".sh"},
55+ filepath: "/usr/local/bin/script.sh",
56+ expected: true,
57+ },
58+ {
59+ name: "case insensitive matching",
60+ fileTypes: []string{".GO"},
61+ filepath: "main.go",
62+ expected: true,
63+ },
64+ {
65+ name: "bash file types",
66+ fileTypes: []string{".sh", ".bash", ".zsh", ".ksh"},
67+ filepath: "script.sh",
68+ expected: true,
69+ },
70+ {
71+ name: "bash should not handle go files",
72+ fileTypes: []string{".sh", ".bash", ".zsh", ".ksh"},
73+ filepath: "main.go",
74+ expected: false,
75+ },
76+ {
77+ name: "bash should not handle json files",
78+ fileTypes: []string{".sh", ".bash", ".zsh", ".ksh"},
79+ filepath: "config.json",
80+ expected: false,
81+ },
82+ }
83+
84+ for _, tt := range tests {
85+ t.Run(tt.name, func(t *testing.T) {
86+ client := &Client{
87+ fileTypes: tt.fileTypes,
88+ }
89+ result := client.HandlesFile(tt.filepath)
90+ require.Equal(t, tt.expected, result)
91+ })
92+ }
93+}
internal/lsp/watcher/watcher.go+10−8
@@ -387,6 +387,10 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
387 return
387 return
388 }
388 }
389
389
390+ if !w.client.HandlesFile(event.Name) {
391+ continue // client doesn't handle this filetype
392+ }
393+
390 uri := string(protocol.URIFromPath(event.Name))
394 uri := string(protocol.URIFromPath(event.Name))
391
395
392 // Add new directories to the watcher
396 // Add new directories to the watcher
@@ -431,8 +435,11 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
431 // Just send the notification if needed
435 // Just send the notification if needed
432 info, err := os.Stat(event.Name)
436 info, err := os.Stat(event.Name)
433 if err != nil {
437 if err != nil {
438+ if !os.IsNotExist(err) {
439+ // Only log if it's not a "file not found" error
434− slog.Error("Error getting file info", "path", event.Name, "error", err)
440+ slog.Debug("Error getting file info", "path", event.Name, "error", err)
435− return
441+ }
442+ continue
436 }
443 }
437 if !info.IsDir() && watchKind&protocol.WatchCreate != 0 {
444 if !info.IsDir() && watchKind&protocol.WatchCreate != 0 {
438 w.debounceHandleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Created))
445 w.debounceHandleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Created))
@@ -801,6 +808,7 @@ func shouldExcludeDir(dirPath string) bool {
801func shouldExcludeFile(filePath string) bool {
808func shouldExcludeFile(filePath string) bool {
802 fileName := filepath.Base(filePath)
809 fileName := filepath.Base(filePath)
803 cfg := config.Get()
810 cfg := config.Get()
811+
804 // Skip dot files
812 // Skip dot files
805 if strings.HasPrefix(fileName, ".") {
813 if strings.HasPrefix(fileName, ".") {
806 return true
814 return true
@@ -812,12 +820,6 @@ func shouldExcludeFile(filePath string) bool {
812 return true
820 return true
813 }
821 }
814
822
815− // Skip temporary files
816− if strings.HasSuffix(filePath, "~") {
817− return true
818− }
819−
820− // Check file size
821 info, err := os.Stat(filePath)
823 info, err := os.Stat(filePath)
822 if err != nil {
824 if err != nil {
823 // If we can't stat the file, skip it
825 // If we can't stat the file, skip it
schema.json+11
@@ -65,6 +65,17 @@
65 },
65 },
66 "options": {
66 "options": {
67 "description": "LSP server-specific configuration options"
67 "description": "LSP server-specific configuration options"
68+ },
69+ "filetypes": {
70+ "items": {
71+ "type": "string",
72+ "examples": [
73+ ".go",
74+ ".mod"
75+ ]
76+ },
77+ "type": "array",
78+ "description": "File extensions this LSP server handles (e.g. .go .rs .py)"
68 }
79 }
69 },
80 },
70 "additionalProperties": false,
81 "additionalProperties": false,