Detailed changes
@@ -31,7 +31,7 @@ require (
github.com/charmbracelet/x/exp/ordered v0.1.0
github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff
github.com/charmbracelet/x/exp/strings v0.1.0
- github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b
+ github.com/charmbracelet/x/powernap v0.0.0-20260127155452-b72a9a918687
github.com/charmbracelet/x/term v0.2.2
github.com/denisbrodbeck/machineid v1.0.1
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
@@ -122,8 +122,8 @@ github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9
github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8=
github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
-github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b h1:5ye9hzBKH623bMVz5auIuY6K21loCdxpRmFle2O9R/8=
-github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
+github.com/charmbracelet/x/powernap v0.0.0-20260127155452-b72a9a918687 h1:h1XMgTkpBt9kEJ+9DkARNBXEgaigUQ0cI2Bot7Awnt8=
+github.com/charmbracelet/x/powernap v0.0.0-20260127155452-b72a9a918687/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
@@ -101,7 +101,7 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
app.setupEvents()
// Initialize LSP clients in the background.
- app.initLSPClients(ctx)
+ go app.initLSPClients(ctx)
// Check for updates in the background.
go app.checkForUpdates(ctx)
@@ -3,41 +3,108 @@ package app
import (
"context"
"log/slog"
+ "os/exec"
+ "slices"
"time"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/lsp"
+ powernapconfig "github.com/charmbracelet/x/powernap/pkg/config"
)
// initLSPClients initializes LSP clients.
func (app *App) initLSPClients(ctx context.Context) {
+ slog.Info("LSP clients initialization started")
+
+ manager := powernapconfig.NewManager()
+ manager.LoadDefaults()
+
+ var userConfiguredLSPs []string
for name, clientConfig := range app.config.LSP {
if clientConfig.Disabled {
slog.Info("Skipping disabled LSP client", "name", name)
+ manager.RemoveServer(name)
continue
}
- go app.createAndStartLSPClient(ctx, name, clientConfig)
+
+ // HACK: the user might have the command name in their config, instead
+ // of the actual name. This finds out these cases, and adjusts the name
+ // accordingly.
+ if _, ok := manager.GetServer(name); !ok {
+ for sname, server := range manager.GetServers() {
+ if server.Command == name {
+ name = sname
+ break
+ }
+ }
+ }
+ userConfiguredLSPs = append(userConfiguredLSPs, name)
+ manager.AddServer(name, &powernapconfig.ServerConfig{
+ Command: clientConfig.Command,
+ Args: clientConfig.Args,
+ Environment: clientConfig.Env,
+ FileTypes: clientConfig.FileTypes,
+ RootMarkers: clientConfig.RootMarkers,
+ InitOptions: clientConfig.InitOptions,
+ Settings: clientConfig.Options,
+ })
+ }
+
+ servers := manager.GetServers()
+ filtered := lsp.FilterMatching(app.config.WorkingDir(), servers)
+
+ for _, name := range userConfiguredLSPs {
+ if _, ok := filtered[name]; !ok {
+ updateLSPState(name, lsp.StateDisabled, nil, nil, 0)
+ }
+ }
+ for name, server := range filtered {
+ if app.config.Options.AutoLSP != nil && !*app.config.Options.AutoLSP && !slices.Contains(userConfiguredLSPs, name) {
+ slog.Debug("Ignoring non user-define LSP client due to AutoLSP being disabled", "name", name)
+ continue
+ }
+ go app.createAndStartLSPClient(
+ ctx, name,
+ toOurConfig(server),
+ slices.Contains(userConfiguredLSPs, name),
+ )
}
- 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, config config.LSPConfig) {
- slog.Debug("Creating LSP client", "name", name, "command", config.Command, "fileTypes", config.FileTypes, "args", config.Args)
+func toOurConfig(in *powernapconfig.ServerConfig) config.LSPConfig {
+ return config.LSPConfig{
+ Command: in.Command,
+ Args: in.Args,
+ Env: in.Environment,
+ FileTypes: in.FileTypes,
+ RootMarkers: in.RootMarkers,
+ InitOptions: in.InitOptions,
+ Options: in.Settings,
+ }
+}
- // Check if any root markers exist in the working directory (config now has defaults)
- if !lsp.HasRootMarkers(app.config.WorkingDir(), config.RootMarkers) {
- slog.Debug("Skipping LSP client: no root markers found", "name", name, "rootMarkers", config.RootMarkers)
- updateLSPState(name, lsp.StateDisabled, nil, nil, 0)
- return
+// createAndStartLSPClient creates a new LSP client, initializes it, and starts its workspace watcher.
+func (app *App) createAndStartLSPClient(ctx context.Context, name string, config config.LSPConfig, userConfigured bool) {
+ if !userConfigured {
+ if _, err := exec.LookPath(config.Command); err != nil {
+ slog.Warn("Default LSP config skipped: server not installed", "name", name, "error", err)
+ return
+ }
}
- // Update state to starting
+ slog.Debug("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.New(ctx, name, config, app.config.Resolver())
if err != nil {
+ if !userConfigured {
+ slog.Warn("Default LSP config skipped due to error", "name", name, "error", err)
+ updateLSPState(name, lsp.StateDisabled, nil, nil, 0)
+ return
+ }
slog.Error("Failed to create LSP client for", "name", name, "error", err)
updateLSPState(name, lsp.StateError, err, nil, 0)
return
@@ -257,6 +257,7 @@ type Options struct {
Attribution *Attribution `json:"attribution,omitempty" jsonschema:"description=Attribution settings for generated content"`
DisableMetrics bool `json:"disable_metrics,omitempty" jsonschema:"description=Disable sending metrics,default=false"`
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"`
+ AutoLSP *bool `json:"auto_lsp,omitempty" jsonschema:"description=Automatically setup LSPs based on root markers"`
}
type MCPs map[string]MCPConfig
@@ -13,10 +13,12 @@ import (
"sync/atomic"
"time"
+ "github.com/bmatcuk/doublestar/v4"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/csync"
"github.com/charmbracelet/crush/internal/fsext"
"github.com/charmbracelet/crush/internal/home"
+ powernapconfig "github.com/charmbracelet/x/powernap/pkg/config"
powernap "github.com/charmbracelet/x/powernap/pkg/lsp"
"github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
"github.com/charmbracelet/x/powernap/pkg/transport"
@@ -329,14 +331,15 @@ func (c *Client) HandlesFile(path string) bool {
return true
}
+ kind := powernap.DetectLanguage(path)
name := strings.ToLower(filepath.Base(path))
for _, filetype := range c.fileTypes {
suffix := strings.ToLower(filetype)
if !strings.HasPrefix(suffix, ".") {
suffix = "." + suffix
}
- if strings.HasSuffix(name, suffix) {
- slog.Debug("handles file", "name", c.name, "file", name, "filetype", filetype)
+ if strings.HasSuffix(name, suffix) || filetype == string(kind) {
+ slog.Debug("handles file", "name", c.name, "file", name, "filetype", filetype, "kind", kind)
return true
}
}
@@ -363,7 +366,7 @@ func (c *Client) OpenFile(ctx context.Context, filepath string) error {
}
// Notify the server about the opened document
- if err = c.client.NotifyDidOpenTextDocument(ctx, uri, string(DetectLanguageID(uri)), 1, string(content)); err != nil {
+ if err = c.client.NotifyDidOpenTextDocument(ctx, uri, string(powernap.DetectLanguage(filepath)), 1, string(content)); err != nil {
return err
}
@@ -574,18 +577,66 @@ func (c *Client) FindReferences(ctx context.Context, filepath string, line, char
return c.client.FindReferences(ctx, filepath, line-1, character-1, includeDeclaration)
}
-// HasRootMarkers checks if any of the specified root marker patterns exist in the given directory.
-// Uses glob patterns to match files, allowing for more flexible matching.
-func HasRootMarkers(dir string, rootMarkers []string) bool {
- if len(rootMarkers) == 0 {
- return true
+// FilterMatching gets a list of configs and only returns the ones with
+// matching root markers.
+func FilterMatching(dir string, servers map[string]*powernapconfig.ServerConfig) map[string]*powernapconfig.ServerConfig {
+ result := map[string]*powernapconfig.ServerConfig{}
+ if len(servers) == 0 {
+ return result
}
- for _, pattern := range rootMarkers {
- // Use fsext.GlobWithDoubleStar to find matches
- matches, _, err := fsext.GlobWithDoubleStar(pattern, dir, 1)
- if err == nil && len(matches) > 0 {
- return true
+
+ type serverPatterns struct {
+ server *powernapconfig.ServerConfig
+ patterns []string
+ }
+ normalized := make(map[string]serverPatterns, len(servers))
+ for name, server := range servers {
+ if len(server.RootMarkers) == 0 {
+ continue
+ }
+ patterns := make([]string, len(server.RootMarkers))
+ for i, p := range server.RootMarkers {
+ patterns[i] = filepath.ToSlash(p)
}
+ normalized[name] = serverPatterns{server: server, patterns: patterns}
}
- return false
+
+ walker := fsext.NewFastGlobWalker(dir)
+ _ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
+ if err != nil {
+ return nil
+ }
+
+ if walker.ShouldSkip(path) {
+ if d.IsDir() {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+
+ relPath, err := filepath.Rel(dir, path)
+ if err != nil {
+ return nil
+ }
+ relPath = filepath.ToSlash(relPath)
+
+ for name, sp := range normalized {
+ for _, pattern := range sp.patterns {
+ matched, err := doublestar.Match(pattern, relPath)
+ if err != nil || !matched {
+ continue
+ }
+ result[name] = sp.server
+ delete(normalized, name)
+ break
+ }
+ }
+
+ if len(normalized) == 0 {
+ return filepath.SkipAll
+ }
+ return nil
+ })
+
+ return result
}
@@ -0,0 +1,111 @@
+package lsp
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ powernapconfig "github.com/charmbracelet/x/powernap/pkg/config"
+ "github.com/stretchr/testify/require"
+)
+
+func TestFilterMatching(t *testing.T) {
+ t.Parallel()
+
+ t.Run("matches servers with existing root markers", func(t *testing.T) {
+ t.Parallel()
+ tmpDir := t.TempDir()
+
+ require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module test"), 0o644))
+ require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "Cargo.toml"), []byte("[package]"), 0o644))
+
+ servers := map[string]*powernapconfig.ServerConfig{
+ "gopls": {RootMarkers: []string{"go.mod", "go.work"}},
+ "rust-analyzer": {RootMarkers: []string{"Cargo.toml"}},
+ "typescript-lsp": {RootMarkers: []string{"package.json", "tsconfig.json"}},
+ }
+
+ result := FilterMatching(tmpDir, servers)
+
+ require.Contains(t, result, "gopls")
+ require.Contains(t, result, "rust-analyzer")
+ require.NotContains(t, result, "typescript-lsp")
+ })
+
+ t.Run("returns empty for empty servers", func(t *testing.T) {
+ t.Parallel()
+ tmpDir := t.TempDir()
+
+ result := FilterMatching(tmpDir, map[string]*powernapconfig.ServerConfig{})
+
+ require.Empty(t, result)
+ })
+
+ t.Run("returns empty when no markers match", func(t *testing.T) {
+ t.Parallel()
+ tmpDir := t.TempDir()
+
+ servers := map[string]*powernapconfig.ServerConfig{
+ "gopls": {RootMarkers: []string{"go.mod"}},
+ "python": {RootMarkers: []string{"pyproject.toml"}},
+ }
+
+ result := FilterMatching(tmpDir, servers)
+
+ require.Empty(t, result)
+ })
+
+ t.Run("glob patterns work", func(t *testing.T) {
+ t.Parallel()
+ tmpDir := t.TempDir()
+
+ require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "src"), 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "src", "main.go"), []byte("package main"), 0o644))
+
+ servers := map[string]*powernapconfig.ServerConfig{
+ "gopls": {RootMarkers: []string{"**/*.go"}},
+ "python": {RootMarkers: []string{"**/*.py"}},
+ }
+
+ result := FilterMatching(tmpDir, servers)
+
+ require.Contains(t, result, "gopls")
+ require.NotContains(t, result, "python")
+ })
+
+ t.Run("servers with empty root markers are not included", func(t *testing.T) {
+ t.Parallel()
+ tmpDir := t.TempDir()
+
+ require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module test"), 0o644))
+
+ servers := map[string]*powernapconfig.ServerConfig{
+ "gopls": {RootMarkers: []string{"go.mod"}},
+ "generic": {RootMarkers: []string{}},
+ }
+
+ result := FilterMatching(tmpDir, servers)
+
+ require.Contains(t, result, "gopls")
+ require.NotContains(t, result, "generic")
+ })
+
+ t.Run("stops early when all servers match", func(t *testing.T) {
+ t.Parallel()
+ tmpDir := t.TempDir()
+
+ require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module test"), 0o644))
+ require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "Cargo.toml"), []byte("[package]"), 0o644))
+
+ servers := map[string]*powernapconfig.ServerConfig{
+ "gopls": {RootMarkers: []string{"go.mod"}},
+ "rust-analyzer": {RootMarkers: []string{"Cargo.toml"}},
+ }
+
+ result := FilterMatching(tmpDir, servers)
+
+ require.Len(t, result, 2)
+ require.Contains(t, result, "gopls")
+ require.Contains(t, result, "rust-analyzer")
+ })
+}
@@ -1,132 +0,0 @@
-package lsp
-
-import (
- "path/filepath"
- "strings"
-
- "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
-)
-
-func DetectLanguageID(uri string) protocol.LanguageKind {
- ext := strings.ToLower(filepath.Ext(uri))
- switch ext {
- case ".abap":
- return protocol.LangABAP
- case ".bat":
- return protocol.LangWindowsBat
- case ".bib", ".bibtex":
- return protocol.LangBibTeX
- case ".clj":
- return protocol.LangClojure
- case ".coffee":
- return protocol.LangCoffeescript
- case ".c":
- return protocol.LangC
- case ".cpp", ".cxx", ".cc", ".c++":
- return protocol.LangCPP
- case ".cs":
- return protocol.LangCSharp
- case ".css":
- return protocol.LangCSS
- case ".d":
- return protocol.LangD
- case ".pas", ".pascal":
- return protocol.LangDelphi
- case ".diff", ".patch":
- return protocol.LangDiff
- case ".dart":
- return protocol.LangDart
- case ".dockerfile":
- return protocol.LangDockerfile
- case ".ex", ".exs":
- return protocol.LangElixir
- case ".erl", ".hrl":
- return protocol.LangErlang
- case ".fs", ".fsi", ".fsx", ".fsscript":
- return protocol.LangFSharp
- case ".gitcommit":
- return protocol.LangGitCommit
- case ".gitrebase":
- return protocol.LangGitRebase
- case ".go":
- return protocol.LangGo
- case ".groovy":
- return protocol.LangGroovy
- case ".hbs", ".handlebars":
- return protocol.LangHandlebars
- case ".hs":
- return protocol.LangHaskell
- case ".html", ".htm":
- return protocol.LangHTML
- case ".ini":
- return protocol.LangIni
- case ".java":
- return protocol.LangJava
- case ".js":
- return protocol.LangJavaScript
- case ".jsx":
- return protocol.LangJavaScriptReact
- case ".json":
- return protocol.LangJSON
- case ".tex", ".latex":
- return protocol.LangLaTeX
- case ".less":
- return protocol.LangLess
- case ".lua":
- return protocol.LangLua
- case ".makefile", "makefile":
- return protocol.LangMakefile
- case ".md", ".markdown":
- return protocol.LangMarkdown
- case ".m":
- return protocol.LangObjectiveC
- case ".mm":
- return protocol.LangObjectiveCPP
- case ".pl":
- return protocol.LangPerl
- case ".pm":
- return protocol.LangPerl6
- case ".php":
- return protocol.LangPHP
- case ".ps1", ".psm1":
- return protocol.LangPowershell
- case ".pug", ".jade":
- return protocol.LangPug
- case ".py":
- return protocol.LangPython
- case ".r":
- return protocol.LangR
- case ".cshtml", ".razor":
- return protocol.LangRazor
- case ".rb":
- return protocol.LangRuby
- case ".rs":
- return protocol.LangRust
- case ".scss":
- return protocol.LangSCSS
- case ".sass":
- return protocol.LangSASS
- case ".scala":
- return protocol.LangScala
- case ".shader":
- return protocol.LangShaderLab
- case ".sh", ".bash", ".zsh", ".ksh":
- return protocol.LangShellScript
- case ".sql":
- return protocol.LangSQL
- case ".swift":
- return protocol.LangSwift
- case ".ts":
- return protocol.LangTypeScript
- case ".tsx":
- return protocol.LangTypeScriptReact
- case ".xml":
- return protocol.LangXML
- case ".xsl":
- return protocol.LangXSL
- case ".yaml", ".yml":
- return protocol.LangYAML
- default:
- return protocol.LanguageKind("") // Unknown language
- }
-}
@@ -1,37 +0,0 @@
-package lsp
-
-import (
- "os"
- "path/filepath"
- "testing"
-
- "github.com/stretchr/testify/require"
-)
-
-func TestHasRootMarkers(t *testing.T) {
- t.Parallel()
-
- // Create a temporary directory for testing
- tmpDir := t.TempDir()
-
- // Test with empty root markers (should return true)
- require.True(t, HasRootMarkers(tmpDir, []string{}))
-
- // Test with non-existent markers
- require.False(t, HasRootMarkers(tmpDir, []string{"go.mod", "package.json"}))
-
- // Create a go.mod file
- goModPath := filepath.Join(tmpDir, "go.mod")
- err := os.WriteFile(goModPath, []byte("module test"), 0o644)
- require.NoError(t, err)
-
- // Test with existing marker
- require.True(t, HasRootMarkers(tmpDir, []string{"go.mod", "package.json"}))
-
- // Test with only non-existent markers
- require.False(t, HasRootMarkers(tmpDir, []string{"package.json", "Cargo.toml"}))
-
- // Test with glob patterns
- require.True(t, HasRootMarkers(tmpDir, []string{"*.mod"}))
- require.False(t, HasRootMarkers(tmpDir, []string{"*.json"}))
-}
@@ -480,8 +480,6 @@ func (m *sidebarCmp) filesBlock() string {
func (m *sidebarCmp) lspBlock() string {
// Limit the number of LSPs shown
_, maxLSPs, _ := m.getDynamicLimits()
- lspConfigs := config.Get().LSP.Sorted()
- maxLSPs = min(len(lspConfigs), maxLSPs)
return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{
MaxWidth: m.getMaxWidth(),
@@ -2,6 +2,8 @@ package lsp
import (
"fmt"
+ "maps"
+ "slices"
"strings"
"charm.land/lipgloss/v2"
@@ -35,32 +37,32 @@ func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOption
lspList = append(lspList, section, "")
}
- lspConfigs := config.Get().LSP.Sorted()
- if len(lspConfigs) == 0 {
+ // Get LSP states
+ lsps := slices.SortedFunc(maps.Values(app.GetLSPStates()), func(a, b app.LSPClientInfo) int {
+ return strings.Compare(a.Name, b.Name)
+ })
+ if len(lsps) == 0 {
lspList = append(lspList, t.S().Base.Foreground(t.Border).Render("None"))
return lspList
}
- // Get LSP states
- lspStates := app.GetLSPStates()
-
// Determine how many items to show
- maxItems := len(lspConfigs)
+ maxItems := len(lsps)
if opts.MaxItems > 0 {
- maxItems = min(opts.MaxItems, len(lspConfigs))
+ maxItems = min(opts.MaxItems, len(lsps))
}
- for i, l := range lspConfigs {
+ for i, info := range lsps {
if i >= maxItems {
break
}
- icon, description := iconAndDescription(l, t, lspStates)
+ icon, description := iconAndDescription(t, info)
// Calculate diagnostic counts if we have LSP clients
var extraContent string
if lspClients != nil {
- if client, ok := lspClients.Get(l.Name); ok {
+ if client, ok := lspClients.Get(info.Name); ok {
counts := client.GetDiagnosticCounts()
errs := []string{}
if counts.Error > 0 {
@@ -83,7 +85,7 @@ func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOption
core.Status(
core.StatusOpts{
Icon: icon.String(),
- Title: l.Name,
+ Title: info.Name,
Description: description,
ExtraContent: extraContent,
},
@@ -95,12 +97,7 @@ func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOption
return lspList
}
-func iconAndDescription(l config.LSP, t *styles.Theme, states map[string]app.LSPClientInfo) (lipgloss.Style, string) {
- if l.LSP.Disabled {
- return t.ItemOfflineIcon.Foreground(t.FgMuted), t.S().Subtle.Render("disabled")
- }
-
- info := states[l.Name]
+func iconAndDescription(t *styles.Theme, info app.LSPClientInfo) (lipgloss.Style, string) {
switch info.State {
case lsp.StateStarting:
return t.ItemBusyIcon, t.S().Subtle.Render("starting...")