on main help sign in
feat(lsp): auto-discover LSPs (#1834)
c81b02fCarlos Alexandro Becker
go.mod+1−1
@@ -31,7 +31,7 @@ require (
31 github.com/charmbracelet/x/exp/ordered v0.1.0
31 github.com/charmbracelet/x/exp/ordered v0.1.0
32 github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff
32 github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff
33 github.com/charmbracelet/x/exp/strings v0.1.0
33 github.com/charmbracelet/x/exp/strings v0.1.0
34− github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b
34+ github.com/charmbracelet/x/powernap v0.0.0-20260127155452-b72a9a918687
35 github.com/charmbracelet/x/term v0.2.2
35 github.com/charmbracelet/x/term v0.2.2
36 github.com/denisbrodbeck/machineid v1.0.1
36 github.com/denisbrodbeck/machineid v1.0.1
37 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
37 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
go.sum+2−2
@@ -122,8 +122,8 @@ github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9
122github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8=
122github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8=
123github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
123github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
124github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
124github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
125−github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b h1:5ye9hzBKH623bMVz5auIuY6K21loCdxpRmFle2O9R/8=
125+github.com/charmbracelet/x/powernap v0.0.0-20260127155452-b72a9a918687 h1:h1XMgTkpBt9kEJ+9DkARNBXEgaigUQ0cI2Bot7Awnt8=
126−github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
126+github.com/charmbracelet/x/powernap v0.0.0-20260127155452-b72a9a918687/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
127github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
127github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
128github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
128github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
129github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
129github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
internal/app/app.go+1−1
@@ -101,7 +101,7 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
101 app.setupEvents()
101 app.setupEvents()
102
102
103 // Initialize LSP clients in the background.
103 // Initialize LSP clients in the background.
104− app.initLSPClients(ctx)
104+ go app.initLSPClients(ctx)
105
105
106 // Check for updates in the background.
106 // Check for updates in the background.
107 go app.checkForUpdates(ctx)
107 go app.checkForUpdates(ctx)
internal/app/lsp.go+78−11
@@ -3,41 +3,108 @@ package app
3import (
3import (
4 "context"
4 "context"
5 "log/slog"
5 "log/slog"
6+ "os/exec"
7+ "slices"
6 "time"
8 "time"
7
9
8 "github.com/charmbracelet/crush/internal/config"
10 "github.com/charmbracelet/crush/internal/config"
9 "github.com/charmbracelet/crush/internal/lsp"
11 "github.com/charmbracelet/crush/internal/lsp"
12+ powernapconfig "github.com/charmbracelet/x/powernap/pkg/config"
10)
13)
11
14
12// initLSPClients initializes LSP clients.
15// initLSPClients initializes LSP clients.
13func (app *App) initLSPClients(ctx context.Context) {
16func (app *App) initLSPClients(ctx context.Context) {
17+ slog.Info("LSP clients initialization started")
18+
19+ manager := powernapconfig.NewManager()
20+ manager.LoadDefaults()
21+
22+ var userConfiguredLSPs []string
14 for name, clientConfig := range app.config.LSP {
23 for name, clientConfig := range app.config.LSP {
15 if clientConfig.Disabled {
24 if clientConfig.Disabled {
16 slog.Info("Skipping disabled LSP client", "name", name)
25 slog.Info("Skipping disabled LSP client", "name", name)
26+ manager.RemoveServer(name)
17 continue
27 continue
18 }
28 }
29+
30+ // HACK: the user might have the command name in their config, instead
31+ // of the actual name. This finds out these cases, and adjusts the name
32+ // accordingly.
33+ if _, ok := manager.GetServer(name); !ok {
34+ for sname, server := range manager.GetServers() {
35+ if server.Command == name {
36+ name = sname
37+ break
38+ }
39+ }
40+ }
41+ userConfiguredLSPs = append(userConfiguredLSPs, name)
42+ manager.AddServer(name, &powernapconfig.ServerConfig{
43+ Command: clientConfig.Command,
44+ Args: clientConfig.Args,
45+ Environment: clientConfig.Env,
46+ FileTypes: clientConfig.FileTypes,
47+ RootMarkers: clientConfig.RootMarkers,
48+ InitOptions: clientConfig.InitOptions,
49+ Settings: clientConfig.Options,
50+ })
51+ }
52+
53+ servers := manager.GetServers()
54+ filtered := lsp.FilterMatching(app.config.WorkingDir(), servers)
55+
56+ for _, name := range userConfiguredLSPs {
57+ if _, ok := filtered[name]; !ok {
58+ updateLSPState(name, lsp.StateDisabled, nil, nil, 0)
59+ }
60+ }
61+ for name, server := range filtered {
62+ if app.config.Options.AutoLSP != nil && !*app.config.Options.AutoLSP && !slices.Contains(userConfiguredLSPs, name) {
63+ slog.Debug("Ignoring non user-define LSP client due to AutoLSP being disabled", "name", name)
64+ continue
65+ }
19− go app.createAndStartLSPClient(ctx, name, clientConfig)
66+ go app.createAndStartLSPClient(
67+ ctx, name,
68+ toOurConfig(server),
69+ slices.Contains(userConfiguredLSPs, name),
70+ )
20 }
71 }
21− slog.Info("LSP clients initialization started in background")
22}
72}
23
73
24−// createAndStartLSPClient creates a new LSP client, initializes it, and starts its workspace watcher
74+func toOurConfig(in *powernapconfig.ServerConfig) config.LSPConfig {
25−func (app *App) createAndStartLSPClient(ctx context.Context, name string, config config.LSPConfig) {
75+ return config.LSPConfig{
26− slog.Debug("Creating LSP client", "name", name, "command", config.Command, "fileTypes", config.FileTypes, "args", config.Args)
76+ Command: in.Command,
77+ Args: in.Args,
78+ Env: in.Environment,
79+ FileTypes: in.FileTypes,
80+ RootMarkers: in.RootMarkers,
81+ InitOptions: in.InitOptions,
82+ Options: in.Settings,
83+ }
84+}
27
85
28− // Check if any root markers exist in the working directory (config now has defaults)
86+// createAndStartLSPClient creates a new LSP client, initializes it, and starts its workspace watcher.
29− if !lsp.HasRootMarkers(app.config.WorkingDir(), config.RootMarkers) {
87+func (app *App) createAndStartLSPClient(ctx context.Context, name string, config config.LSPConfig, userConfigured bool) {
30− slog.Debug("Skipping LSP client: no root markers found", "name", name, "rootMarkers", config.RootMarkers)
88+ if !userConfigured {
31− updateLSPState(name, lsp.StateDisabled, nil, nil, 0)
89+ if _, err := exec.LookPath(config.Command); err != nil {
90+ slog.Warn("Default LSP config skipped: server not installed", "name", name, "error", err)
32− return
91+ return
92+ }
33 }
93 }
34
94
95+ slog.Debug("Creating LSP client", "name", name, "command", config.Command, "fileTypes", config.FileTypes, "args", config.Args)
96+
35− // Update state to starting
97+ // Update state to starting.
36 updateLSPState(name, lsp.StateStarting, nil, nil, 0)
98 updateLSPState(name, lsp.StateStarting, nil, nil, 0)
37
99
38 // Create LSP client.
100 // Create LSP client.
39 lspClient, err := lsp.New(ctx, name, config, app.config.Resolver())
101 lspClient, err := lsp.New(ctx, name, config, app.config.Resolver())
40 if err != nil {
102 if err != nil {
103+ if !userConfigured {
104+ slog.Warn("Default LSP config skipped due to error", "name", name, "error", err)
105+ updateLSPState(name, lsp.StateDisabled, nil, nil, 0)
106+ return
107+ }
41 slog.Error("Failed to create LSP client for", "name", name, "error", err)
108 slog.Error("Failed to create LSP client for", "name", name, "error", err)
42 updateLSPState(name, lsp.StateError, err, nil, 0)
109 updateLSPState(name, lsp.StateError, err, nil, 0)
43 return
110 return
internal/config/config.go+1
@@ -257,6 +257,7 @@ type Options struct {
257 Attribution *Attribution `json:"attribution,omitempty" jsonschema:"description=Attribution settings for generated content"`
257 Attribution *Attribution `json:"attribution,omitempty" jsonschema:"description=Attribution settings for generated content"`
258 DisableMetrics bool `json:"disable_metrics,omitempty" jsonschema:"description=Disable sending metrics,default=false"`
258 DisableMetrics bool `json:"disable_metrics,omitempty" jsonschema:"description=Disable sending metrics,default=false"`
259 InitializeAs string `json:"initialize_as,omitempty" jsonschema:"description=Name of the context file to create/update during project initialization,default=AGENTS.md,example=AGENTS.md,example=CRUSH.md,example=CLAUDE.md,example=docs/LLMs.md"`
259 InitializeAs string `json:"initialize_as,omitempty" jsonschema:"description=Name of the context file to create/update during project initialization,default=AGENTS.md,example=AGENTS.md,example=CRUSH.md,example=CLAUDE.md,example=docs/LLMs.md"`
260+ AutoLSP *bool `json:"auto_lsp,omitempty" jsonschema:"description=Automatically setup LSPs based on root markers"`
260}
261}
261
262
262type MCPs map[string]MCPConfig
263type MCPs map[string]MCPConfig
internal/lsp/client.go+65−14
@@ -13,10 +13,12 @@ import (
13 "sync/atomic"
13 "sync/atomic"
14 "time"
14 "time"
15
15
16+ "github.com/bmatcuk/doublestar/v4"
16 "github.com/charmbracelet/crush/internal/config"
17 "github.com/charmbracelet/crush/internal/config"
17 "github.com/charmbracelet/crush/internal/csync"
18 "github.com/charmbracelet/crush/internal/csync"
18 "github.com/charmbracelet/crush/internal/fsext"
19 "github.com/charmbracelet/crush/internal/fsext"
19 "github.com/charmbracelet/crush/internal/home"
20 "github.com/charmbracelet/crush/internal/home"
21+ powernapconfig "github.com/charmbracelet/x/powernap/pkg/config"
20 powernap "github.com/charmbracelet/x/powernap/pkg/lsp"
22 powernap "github.com/charmbracelet/x/powernap/pkg/lsp"
21 "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
23 "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
22 "github.com/charmbracelet/x/powernap/pkg/transport"
24 "github.com/charmbracelet/x/powernap/pkg/transport"
@@ -329,14 +331,15 @@ func (c *Client) HandlesFile(path string) bool {
329 return true
331 return true
330 }
332 }
331
333
334+ kind := powernap.DetectLanguage(path)
332 name := strings.ToLower(filepath.Base(path))
335 name := strings.ToLower(filepath.Base(path))
333 for _, filetype := range c.fileTypes {
336 for _, filetype := range c.fileTypes {
334 suffix := strings.ToLower(filetype)
337 suffix := strings.ToLower(filetype)
335 if !strings.HasPrefix(suffix, ".") {
338 if !strings.HasPrefix(suffix, ".") {
336 suffix = "." + suffix
339 suffix = "." + suffix
337 }
340 }
338− if strings.HasSuffix(name, suffix) {
341+ if strings.HasSuffix(name, suffix) || filetype == string(kind) {
339− slog.Debug("handles file", "name", c.name, "file", name, "filetype", filetype)
342+ slog.Debug("handles file", "name", c.name, "file", name, "filetype", filetype, "kind", kind)
340 return true
343 return true
341 }
344 }
342 }
345 }
@@ -363,7 +366,7 @@ func (c *Client) OpenFile(ctx context.Context, filepath string) error {
363 }
366 }
364
367
365 // Notify the server about the opened document
368 // Notify the server about the opened document
366− if err = c.client.NotifyDidOpenTextDocument(ctx, uri, string(DetectLanguageID(uri)), 1, string(content)); err != nil {
369+ if err = c.client.NotifyDidOpenTextDocument(ctx, uri, string(powernap.DetectLanguage(filepath)), 1, string(content)); err != nil {
367 return err
370 return err
368 }
371 }
369
372
@@ -574,18 +577,66 @@ func (c *Client) FindReferences(ctx context.Context, filepath string, line, char
574 return c.client.FindReferences(ctx, filepath, line-1, character-1, includeDeclaration)
577 return c.client.FindReferences(ctx, filepath, line-1, character-1, includeDeclaration)
575}
578}
576
579
577−// HasRootMarkers checks if any of the specified root marker patterns exist in the given directory.
580+// FilterMatching gets a list of configs and only returns the ones with
578−// Uses glob patterns to match files, allowing for more flexible matching.
581+// matching root markers.
579−func HasRootMarkers(dir string, rootMarkers []string) bool {
582+func FilterMatching(dir string, servers map[string]*powernapconfig.ServerConfig) map[string]*powernapconfig.ServerConfig {
583+ result := map[string]*powernapconfig.ServerConfig{}
580− if len(rootMarkers) == 0 {
584+ if len(servers) == 0 {
581− return true
585+ return result
582 }
586 }
583− for _, pattern := range rootMarkers {
587+
584− // Use fsext.GlobWithDoubleStar to find matches
588+ type serverPatterns struct {
585− matches, _, err := fsext.GlobWithDoubleStar(pattern, dir, 1)
589+ server *powernapconfig.ServerConfig
586− if err == nil && len(matches) > 0 {
590+ patterns []string
587− return true
591+ }
592+ normalized := make(map[string]serverPatterns, len(servers))
593+ for name, server := range servers {
594+ if len(server.RootMarkers) == 0 {
595+ continue
596+ }
597+ patterns := make([]string, len(server.RootMarkers))
598+ for i, p := range server.RootMarkers {
599+ patterns[i] = filepath.ToSlash(p)
588 }
600 }
601+ normalized[name] = serverPatterns{server: server, patterns: patterns}
589 }
602 }
603+
604+ walker := fsext.NewFastGlobWalker(dir)
605+ _ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
606+ if err != nil {
590− return false
607+ return nil
608+ }
609+
610+ if walker.ShouldSkip(path) {
611+ if d.IsDir() {
612+ return filepath.SkipDir
613+ }
614+ return nil
615+ }
616+
617+ relPath, err := filepath.Rel(dir, path)
618+ if err != nil {
619+ return nil
620+ }
621+ relPath = filepath.ToSlash(relPath)
622+
623+ for name, sp := range normalized {
624+ for _, pattern := range sp.patterns {
625+ matched, err := doublestar.Match(pattern, relPath)
626+ if err != nil || !matched {
627+ continue
628+ }
629+ result[name] = sp.server
630+ delete(normalized, name)
631+ break
632+ }
633+ }
634+
635+ if len(normalized) == 0 {
636+ return filepath.SkipAll
637+ }
638+ return nil
639+ })
640+
641+ return result
591}
642}
internal/lsp/filtermatching_test.gonew file+111
@@ -0,0 +1,111 @@
1+package lsp
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ powernapconfig "github.com/charmbracelet/x/powernap/pkg/config"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestFilterMatching(t *testing.T) {
13+ t.Parallel()
14+
15+ t.Run("matches servers with existing root markers", func(t *testing.T) {
16+ t.Parallel()
17+ tmpDir := t.TempDir()
18+
19+ require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module test"), 0o644))
20+ require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "Cargo.toml"), []byte("[package]"), 0o644))
21+
22+ servers := map[string]*powernapconfig.ServerConfig{
23+ "gopls": {RootMarkers: []string{"go.mod", "go.work"}},
24+ "rust-analyzer": {RootMarkers: []string{"Cargo.toml"}},
25+ "typescript-lsp": {RootMarkers: []string{"package.json", "tsconfig.json"}},
26+ }
27+
28+ result := FilterMatching(tmpDir, servers)
29+
30+ require.Contains(t, result, "gopls")
31+ require.Contains(t, result, "rust-analyzer")
32+ require.NotContains(t, result, "typescript-lsp")
33+ })
34+
35+ t.Run("returns empty for empty servers", func(t *testing.T) {
36+ t.Parallel()
37+ tmpDir := t.TempDir()
38+
39+ result := FilterMatching(tmpDir, map[string]*powernapconfig.ServerConfig{})
40+
41+ require.Empty(t, result)
42+ })
43+
44+ t.Run("returns empty when no markers match", func(t *testing.T) {
45+ t.Parallel()
46+ tmpDir := t.TempDir()
47+
48+ servers := map[string]*powernapconfig.ServerConfig{
49+ "gopls": {RootMarkers: []string{"go.mod"}},
50+ "python": {RootMarkers: []string{"pyproject.toml"}},
51+ }
52+
53+ result := FilterMatching(tmpDir, servers)
54+
55+ require.Empty(t, result)
56+ })
57+
58+ t.Run("glob patterns work", func(t *testing.T) {
59+ t.Parallel()
60+ tmpDir := t.TempDir()
61+
62+ require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "src"), 0o755))
63+ require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "src", "main.go"), []byte("package main"), 0o644))
64+
65+ servers := map[string]*powernapconfig.ServerConfig{
66+ "gopls": {RootMarkers: []string{"**/*.go"}},
67+ "python": {RootMarkers: []string{"**/*.py"}},
68+ }
69+
70+ result := FilterMatching(tmpDir, servers)
71+
72+ require.Contains(t, result, "gopls")
73+ require.NotContains(t, result, "python")
74+ })
75+
76+ t.Run("servers with empty root markers are not included", func(t *testing.T) {
77+ t.Parallel()
78+ tmpDir := t.TempDir()
79+
80+ require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module test"), 0o644))
81+
82+ servers := map[string]*powernapconfig.ServerConfig{
83+ "gopls": {RootMarkers: []string{"go.mod"}},
84+ "generic": {RootMarkers: []string{}},
85+ }
86+
87+ result := FilterMatching(tmpDir, servers)
88+
89+ require.Contains(t, result, "gopls")
90+ require.NotContains(t, result, "generic")
91+ })
92+
93+ t.Run("stops early when all servers match", func(t *testing.T) {
94+ t.Parallel()
95+ tmpDir := t.TempDir()
96+
97+ require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module test"), 0o644))
98+ require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "Cargo.toml"), []byte("[package]"), 0o644))
99+
100+ servers := map[string]*powernapconfig.ServerConfig{
101+ "gopls": {RootMarkers: []string{"go.mod"}},
102+ "rust-analyzer": {RootMarkers: []string{"Cargo.toml"}},
103+ }
104+
105+ result := FilterMatching(tmpDir, servers)
106+
107+ require.Len(t, result, 2)
108+ require.Contains(t, result, "gopls")
109+ require.Contains(t, result, "rust-analyzer")
110+ })
111+}
internal/lsp/language.godeleted−132
@@ -1,132 +0,0 @@
1−package lsp
2−
3−import (
4− "path/filepath"
5− "strings"
6−
7− "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
8−)
9−
10−func DetectLanguageID(uri string) protocol.LanguageKind {
11− ext := strings.ToLower(filepath.Ext(uri))
12− switch ext {
13− case ".abap":
14− return protocol.LangABAP
15− case ".bat":
16− return protocol.LangWindowsBat
17− case ".bib", ".bibtex":
18− return protocol.LangBibTeX
19− case ".clj":
20− return protocol.LangClojure
21− case ".coffee":
22− return protocol.LangCoffeescript
23− case ".c":
24− return protocol.LangC
25− case ".cpp", ".cxx", ".cc", ".c++":
26− return protocol.LangCPP
27− case ".cs":
28− return protocol.LangCSharp
29− case ".css":
30− return protocol.LangCSS
31− case ".d":
32− return protocol.LangD
33− case ".pas", ".pascal":
34− return protocol.LangDelphi
35− case ".diff", ".patch":
36− return protocol.LangDiff
37− case ".dart":
38− return protocol.LangDart
39− case ".dockerfile":
40− return protocol.LangDockerfile
41− case ".ex", ".exs":
42− return protocol.LangElixir
43− case ".erl", ".hrl":
44− return protocol.LangErlang
45− case ".fs", ".fsi", ".fsx", ".fsscript":
46− return protocol.LangFSharp
47− case ".gitcommit":
48− return protocol.LangGitCommit
49− case ".gitrebase":
50− return protocol.LangGitRebase
51− case ".go":
52− return protocol.LangGo
53− case ".groovy":
54− return protocol.LangGroovy
55− case ".hbs", ".handlebars":
56− return protocol.LangHandlebars
57− case ".hs":
58− return protocol.LangHaskell
59− case ".html", ".htm":
60− return protocol.LangHTML
61− case ".ini":
62− return protocol.LangIni
63− case ".java":
64− return protocol.LangJava
65− case ".js":
66− return protocol.LangJavaScript
67− case ".jsx":
68− return protocol.LangJavaScriptReact
69− case ".json":
70− return protocol.LangJSON
71− case ".tex", ".latex":
72− return protocol.LangLaTeX
73− case ".less":
74− return protocol.LangLess
75− case ".lua":
76− return protocol.LangLua
77− case ".makefile", "makefile":
78− return protocol.LangMakefile
79− case ".md", ".markdown":
80− return protocol.LangMarkdown
81− case ".m":
82− return protocol.LangObjectiveC
83− case ".mm":
84− return protocol.LangObjectiveCPP
85− case ".pl":
86− return protocol.LangPerl
87− case ".pm":
88− return protocol.LangPerl6
89− case ".php":
90− return protocol.LangPHP
91− case ".ps1", ".psm1":
92− return protocol.LangPowershell
93− case ".pug", ".jade":
94− return protocol.LangPug
95− case ".py":
96− return protocol.LangPython
97− case ".r":
98− return protocol.LangR
99− case ".cshtml", ".razor":
100− return protocol.LangRazor
101− case ".rb":
102− return protocol.LangRuby
103− case ".rs":
104− return protocol.LangRust
105− case ".scss":
106− return protocol.LangSCSS
107− case ".sass":
108− return protocol.LangSASS
109− case ".scala":
110− return protocol.LangScala
111− case ".shader":
112− return protocol.LangShaderLab
113− case ".sh", ".bash", ".zsh", ".ksh":
114− return protocol.LangShellScript
115− case ".sql":
116− return protocol.LangSQL
117− case ".swift":
118− return protocol.LangSwift
119− case ".ts":
120− return protocol.LangTypeScript
121− case ".tsx":
122− return protocol.LangTypeScriptReact
123− case ".xml":
124− return protocol.LangXML
125− case ".xsl":
126− return protocol.LangXSL
127− case ".yaml", ".yml":
128− return protocol.LangYAML
129− default:
130− return protocol.LanguageKind("") // Unknown language
131− }
132−}
internal/lsp/rootmarkers_test.godeleted−37
@@ -1,37 +0,0 @@
1−package lsp
2−
3−import (
4− "os"
5− "path/filepath"
6− "testing"
7−
8− "github.com/stretchr/testify/require"
9−)
10−
11−func TestHasRootMarkers(t *testing.T) {
12− t.Parallel()
13−
14− // Create a temporary directory for testing
15− tmpDir := t.TempDir()
16−
17− // Test with empty root markers (should return true)
18− require.True(t, HasRootMarkers(tmpDir, []string{}))
19−
20− // Test with non-existent markers
21− require.False(t, HasRootMarkers(tmpDir, []string{"go.mod", "package.json"}))
22−
23− // Create a go.mod file
24− goModPath := filepath.Join(tmpDir, "go.mod")
25− err := os.WriteFile(goModPath, []byte("module test"), 0o644)
26− require.NoError(t, err)
27−
28− // Test with existing marker
29− require.True(t, HasRootMarkers(tmpDir, []string{"go.mod", "package.json"}))
30−
31− // Test with only non-existent markers
32− require.False(t, HasRootMarkers(tmpDir, []string{"package.json", "Cargo.toml"}))
33−
34− // Test with glob patterns
35− require.True(t, HasRootMarkers(tmpDir, []string{"*.mod"}))
36− require.False(t, HasRootMarkers(tmpDir, []string{"*.json"}))
37−}
internal/tui/components/chat/sidebar/sidebar.go−2
@@ -480,8 +480,6 @@ func (m *sidebarCmp) filesBlock() string {
480func (m *sidebarCmp) lspBlock() string {
480func (m *sidebarCmp) lspBlock() string {
481 // Limit the number of LSPs shown
481 // Limit the number of LSPs shown
482 _, maxLSPs, _ := m.getDynamicLimits()
482 _, maxLSPs, _ := m.getDynamicLimits()
483− lspConfigs := config.Get().LSP.Sorted()
484− maxLSPs = min(len(lspConfigs), maxLSPs)
485
483
486 return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{
484 return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{
487 MaxWidth: m.getMaxWidth(),
485 MaxWidth: m.getMaxWidth(),
internal/tui/components/lsp/lsp.go+14−17
@@ -2,6 +2,8 @@ package lsp
2
2
3import (
3import (
4 "fmt"
4 "fmt"
5+ "maps"
6+ "slices"
5 "strings"
7 "strings"
6
8
7 "charm.land/lipgloss/v2"
9 "charm.land/lipgloss/v2"
@@ -35,32 +37,32 @@ func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOption
35 lspList = append(lspList, section, "")
37 lspList = append(lspList, section, "")
36 }
38 }
37
39
38− lspConfigs := config.Get().LSP.Sorted()
40+ // Get LSP states
41+ lsps := slices.SortedFunc(maps.Values(app.GetLSPStates()), func(a, b app.LSPClientInfo) int {
42+ return strings.Compare(a.Name, b.Name)
43+ })
39− if len(lspConfigs) == 0 {
44+ if len(lsps) == 0 {
40 lspList = append(lspList, t.S().Base.Foreground(t.Border).Render("None"))
45 lspList = append(lspList, t.S().Base.Foreground(t.Border).Render("None"))
41 return lspList
46 return lspList
42 }
47 }
43
48
44− // Get LSP states
45− lspStates := app.GetLSPStates()
46−
47 // Determine how many items to show
49 // Determine how many items to show
48− maxItems := len(lspConfigs)
50+ maxItems := len(lsps)
49 if opts.MaxItems > 0 {
51 if opts.MaxItems > 0 {
50− maxItems = min(opts.MaxItems, len(lspConfigs))
52+ maxItems = min(opts.MaxItems, len(lsps))
51 }
53 }
52
54
53− for i, l := range lspConfigs {
55+ for i, info := range lsps {
54 if i >= maxItems {
56 if i >= maxItems {
55 break
57 break
56 }
58 }
57
59
58− icon, description := iconAndDescription(l, t, lspStates)
60+ icon, description := iconAndDescription(t, info)
59
61
60 // Calculate diagnostic counts if we have LSP clients
62 // Calculate diagnostic counts if we have LSP clients
61 var extraContent string
63 var extraContent string
62 if lspClients != nil {
64 if lspClients != nil {
63− if client, ok := lspClients.Get(l.Name); ok {
65+ if client, ok := lspClients.Get(info.Name); ok {
64 counts := client.GetDiagnosticCounts()
66 counts := client.GetDiagnosticCounts()
65 errs := []string{}
67 errs := []string{}
66 if counts.Error > 0 {
68 if counts.Error > 0 {
@@ -83,7 +85,7 @@ func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOption
83 core.Status(
85 core.Status(
84 core.StatusOpts{
86 core.StatusOpts{
85 Icon: icon.String(),
87 Icon: icon.String(),
86− Title: l.Name,
88+ Title: info.Name,
87 Description: description,
89 Description: description,
88 ExtraContent: extraContent,
90 ExtraContent: extraContent,
89 },
91 },
@@ -95,12 +97,7 @@ func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOption
95 return lspList
97 return lspList
96}
98}
97
99
98−func iconAndDescription(l config.LSP, t *styles.Theme, states map[string]app.LSPClientInfo) (lipgloss.Style, string) {
100+func iconAndDescription(t *styles.Theme, info app.LSPClientInfo) (lipgloss.Style, string) {
99− if l.LSP.Disabled {
100− return t.ItemOfflineIcon.Foreground(t.FgMuted), t.S().Subtle.Render("disabled")
101− }
102−
103− info := states[l.Name]
104 switch info.State {
101 switch info.State {
105 case lsp.StateStarting:
102 case lsp.StateStarting:
106 return t.ItemBusyIcon, t.S().Subtle.Render("starting...")
103 return t.ItemBusyIcon, t.S().Subtle.Render("starting...")