on main help sign in
feat(lsp): remove internal watcher (#1062)
016b3b6Carlos Alexandro Becker
go.mod−1
@@ -114,7 +114,6 @@ require (
114 github.com/ncruces/julianday v1.0.0 // indirect
114 github.com/ncruces/julianday v1.0.0 // indirect
115 github.com/pierrec/lz4/v4 v4.1.22 // indirect
115 github.com/pierrec/lz4/v4 v4.1.22 // indirect
116 github.com/pmezard/go-difflib v1.0.0 // indirect
116 github.com/pmezard/go-difflib v1.0.0 // indirect
117− github.com/raphamorim/notify v0.9.4
118 github.com/rivo/uniseg v0.4.7
117 github.com/rivo/uniseg v0.4.7
119 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
118 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
120 github.com/sethvargo/go-retry v0.3.0 // indirect
119 github.com/sethvargo/go-retry v0.3.0 // indirect
go.sum−3
@@ -237,8 +237,6 @@ github.com/pressly/goose/v3 v3.25.0 h1:6WeYhMWGRCzpyd89SpODFnCBCKz41KrVbRT58nVjG
237github.com/pressly/goose/v3 v3.25.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
237github.com/pressly/goose/v3 v3.25.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
238github.com/qjebbs/go-jsons v0.0.0-20221222033332-a534c5fc1c4c h1:kmzxiX+OB0knCo1V0dkEkdPelzCdAzCURCfmFArn2/A=
238github.com/qjebbs/go-jsons v0.0.0-20221222033332-a534c5fc1c4c h1:kmzxiX+OB0knCo1V0dkEkdPelzCdAzCURCfmFArn2/A=
239github.com/qjebbs/go-jsons v0.0.0-20221222033332-a534c5fc1c4c/go.mod h1:wNJrtinHyC3YSf6giEh4FJN8+yZV7nXBjvmfjhBIcw4=
239github.com/qjebbs/go-jsons v0.0.0-20221222033332-a534c5fc1c4c/go.mod h1:wNJrtinHyC3YSf6giEh4FJN8+yZV7nXBjvmfjhBIcw4=
240−github.com/raphamorim/notify v0.9.4 h1:JXAGOzeR/cnclKkRCZINKS4EtB47O5TD1N1iCkkarTM=
241−github.com/raphamorim/notify v0.9.4/go.mod h1:3FXSIPyrunV10GCnLGPrpSxoY/Dxi+saeQb9hf+TDSo=
242github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
240github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
243github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
241github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
244github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
242github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@@ -370,7 +368,6 @@ golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
370golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
368golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
371golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
369golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
372golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
370golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
373−golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
374golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
371golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
375golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
372golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
376golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
373golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
internal/app/app.go−38
@@ -7,14 +7,11 @@ import (
7 "fmt"
7 "fmt"
8 "log/slog"
8 "log/slog"
9 "maps"
9 "maps"
10− "os/exec"
11− "strings"
12 "sync"
10 "sync"
13 "time"
11 "time"
14
12
15 tea "github.com/charmbracelet/bubbletea/v2"
13 tea "github.com/charmbracelet/bubbletea/v2"
16 "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/crush/internal/config"
17− "github.com/charmbracelet/crush/internal/csync"
18 "github.com/charmbracelet/crush/internal/db"
15 "github.com/charmbracelet/crush/internal/db"
19 "github.com/charmbracelet/crush/internal/format"
16 "github.com/charmbracelet/crush/internal/format"
20 "github.com/charmbracelet/crush/internal/history"
17 "github.com/charmbracelet/crush/internal/history"
@@ -23,7 +20,6 @@ import (
23 "github.com/charmbracelet/crush/internal/pubsub"
20 "github.com/charmbracelet/crush/internal/pubsub"
24
21
25 "github.com/charmbracelet/crush/internal/lsp"
22 "github.com/charmbracelet/crush/internal/lsp"
26− "github.com/charmbracelet/crush/internal/lsp/watcher"
27 "github.com/charmbracelet/crush/internal/message"
23 "github.com/charmbracelet/crush/internal/message"
28 "github.com/charmbracelet/crush/internal/permission"
24 "github.com/charmbracelet/crush/internal/permission"
29 "github.com/charmbracelet/crush/internal/session"
25 "github.com/charmbracelet/crush/internal/session"
@@ -41,9 +37,6 @@ type App struct {
41
37
42 clientsMutex sync.RWMutex
38 clientsMutex sync.RWMutex
43
39
44− watcherCancelFuncs *csync.Slice[context.CancelFunc]
45− lspWatcherWG sync.WaitGroup
46−
47 config *config.Config
40 config *config.Config
48
41
49 serviceEventsWG *sync.WaitGroup
42 serviceEventsWG *sync.WaitGroup
@@ -56,16 +49,6 @@ type App struct {
56 cleanupFuncs []func() error
49 cleanupFuncs []func() error
57}
50}
58
51
59−// isGitRepo checks if the current directory is a git repository
60−func isGitRepo() bool {
61− bts, err := exec.CommandContext(
62− context.Background(),
63− "git", "rev-parse",
64− "--is-inside-work-tree",
65− ).CombinedOutput()
66− return err == nil && strings.TrimSpace(string(bts)) == "true"
67−}
68−
69// New initializes a new applcation instance.
52// New initializes a new applcation instance.
70func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
53func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
71 q := db.New(conn)
54 q := db.New(conn)
@@ -89,8 +72,6 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
89
72
90 config: cfg,
73 config: cfg,
91
74
92− watcherCancelFuncs: csync.NewSlice[context.CancelFunc](),
93−
94 events: make(chan tea.Msg, 100),
75 events: make(chan tea.Msg, 100),
95 serviceEventsWG: &sync.WaitGroup{},
76 serviceEventsWG: &sync.WaitGroup{},
96 tuiWG: &sync.WaitGroup{},
77 tuiWG: &sync.WaitGroup{},
@@ -98,15 +79,6 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
98
79
99 app.setupEvents()
80 app.setupEvents()
100
81
101− // Start the global watcher only if this is a git repository
102− if isGitRepo() {
103− if err := watcher.Start(); err != nil {
104− return nil, fmt.Errorf("app: %w", err)
105− }
106− } else {
107− slog.Warn("Not starting global watcher: not a git repository")
108− }
109−
110 // Initialize LSP clients in the background.
82 // Initialize LSP clients in the background.
111 app.initLSPClients(ctx)
83 app.initLSPClients(ctx)
112
84
@@ -352,13 +324,6 @@ func (app *App) Shutdown() {
352 app.CoderAgent.CancelAll()
324 app.CoderAgent.CancelAll()
353 }
325 }
354
326
355− for cancel := range app.watcherCancelFuncs.Seq() {
356− cancel()
357− }
358−
359− // Wait for all LSP watchers to finish.
360− app.lspWatcherWG.Wait()
361−
362 // Get all LSP clients.
327 // Get all LSP clients.
363 app.clientsMutex.RLock()
328 app.clientsMutex.RLock()
364 clients := make(map[string]*lsp.Client, len(app.LSPClients))
329 clients := make(map[string]*lsp.Client, len(app.LSPClients))
@@ -374,9 +339,6 @@ func (app *App) Shutdown() {
374 cancel()
339 cancel()
375 }
340 }
376
341
377− // Shutdown the global watcher
378− watcher.Shutdown()
379−
380 // Call call cleanup functions.
342 // Call call cleanup functions.
381 for _, cleanup := range app.cleanupFuncs {
343 for _, cleanup := range app.cleanupFuncs {
382 if cleanup != nil {
344 if cleanup != nil {
internal/app/lsp.go−58
@@ -6,9 +6,7 @@ import (
6 "time"
6 "time"
7
7
8 "github.com/charmbracelet/crush/internal/config"
8 "github.com/charmbracelet/crush/internal/config"
9− "github.com/charmbracelet/crush/internal/log"
10 "github.com/charmbracelet/crush/internal/lsp"
9 "github.com/charmbracelet/crush/internal/lsp"
11− "github.com/charmbracelet/crush/internal/lsp/watcher"
12)
10)
13
11
14// initLSPClients initializes LSP clients.
12// initLSPClients initializes LSP clients.
@@ -77,64 +75,8 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, config
77
75
78 slog.Info("LSP client initialized", "name", name)
76 slog.Info("LSP client initialized", "name", name)
79
77
80− // Create a child context that can be canceled when the app is shutting
81− // down.
82− watchCtx, cancelFunc := context.WithCancel(ctx)
83−
84− // Create the workspace watcher.
85− workspaceWatcher := watcher.New(name, lspClient)
86−
87− // Store the cancel function to be called during cleanup.
88− app.watcherCancelFuncs.Append(cancelFunc)
89−
90 // Add to map with mutex protection before starting goroutine
78 // Add to map with mutex protection before starting goroutine
91 app.clientsMutex.Lock()
79 app.clientsMutex.Lock()
92 app.LSPClients[name] = lspClient
80 app.LSPClients[name] = lspClient
93 app.clientsMutex.Unlock()
81 app.clientsMutex.Unlock()
94−
95− // Run workspace watcher.
96− app.lspWatcherWG.Add(1)
97− go app.runWorkspaceWatcher(watchCtx, name, workspaceWatcher)
98−}
99−
100−// runWorkspaceWatcher executes the workspace watcher for an LSP client.
101−func (app *App) runWorkspaceWatcher(ctx context.Context, name string, workspaceWatcher *watcher.Client) {
102− defer app.lspWatcherWG.Done()
103− defer log.RecoverPanic("LSP-"+name, func() {
104− // Try to restart the client.
105− app.restartLSPClient(ctx, name)
106− })
107−
108− workspaceWatcher.Watch(ctx, app.config.WorkingDir())
109− slog.Info("Workspace watcher stopped", "client", name)
110−}
111−
112−// restartLSPClient attempts to restart a crashed or failed LSP client.
113−func (app *App) restartLSPClient(ctx context.Context, name string) {
114− // Get the original configuration.
115− clientConfig, exists := app.config.LSP[name]
116− if !exists {
117− slog.Error("Cannot restart client, configuration not found", "client", name)
118− return
119− }
120−
121− // Clean up the old client if it exists.
122− app.clientsMutex.Lock()
123− oldClient, exists := app.LSPClients[name]
124− if exists {
125− // Remove from map before potentially slow shutdown.
126− delete(app.LSPClients, name)
127− }
128− app.clientsMutex.Unlock()
129−
130− if exists && oldClient != nil {
131− // Try to shut down client gracefully, but don't block on errors.
132− shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
133− _ = oldClient.Close(shutdownCtx)
134− cancel()
135− }
136−
137− // Create a new client using the shared function.
138− app.createAndStartLSPClient(ctx, name, clientConfig)
139− slog.Info("Successfully restarted LSP client", "client", name)
140}
82}
internal/csync/maps.go+4−4
@@ -70,10 +70,10 @@ func (m *Map[K, V]) GetOrSet(key K, fn func() V) V {
70
70
71// Take gets an item and then deletes it.
71// Take gets an item and then deletes it.
72func (m *Map[K, V]) Take(key K) (V, bool) {
72func (m *Map[K, V]) Take(key K) (V, bool) {
73− m.mu.Lock()
74− defer m.mu.Unlock()
75− v, ok := m.inner[key]
73+ v, ok := m.Get(key)
76− delete(m.inner, key)
74+ if ok {
75+ m.Del(key)
76+ }
77 return v, ok
77 return v, ok
78}
78}
79
79
internal/csync/versionedmap.gonew file+35
@@ -0,0 +1,35 @@
1+package csync
2+
3+import (
4+ "sync/atomic"
5+)
6+
7+// NewVersionedMap creates a new versioned, thread-safe map.
8+func NewVersionedMap[K comparable, V any]() *VersionedMap[K, V] {
9+ return &VersionedMap[K, V]{
10+ Map: NewMap[K, V](),
11+ }
12+}
13+
14+// VersionedMap is a thread-safe map that keeps track of its version.
15+type VersionedMap[K comparable, V any] struct {
16+ *Map[K, V]
17+ v atomic.Uint64
18+}
19+
20+// Set sets the value for the specified key in the map and increments the version.
21+func (m *VersionedMap[K, V]) Set(key K, value V) {
22+ m.Map.Set(key, value)
23+ m.v.Add(1)
24+}
25+
26+// Del deletes the specified key from the map and increments the version.
27+func (m *VersionedMap[K, V]) Del(key K) {
28+ m.Map.Del(key)
29+ m.v.Add(1)
30+}
31+
32+// Version returns the current version of the map.
33+func (m *VersionedMap[K, V]) Version() uint64 {
34+ return m.v.Load()
35+}
internal/csync/versionedmap_test.gonew file+89
@@ -0,0 +1,89 @@
1+package csync
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestVersionedMap_Set(t *testing.T) {
10+ t.Parallel()
11+
12+ vm := NewVersionedMap[string, int]()
13+ require.Equal(t, uint64(0), vm.Version())
14+
15+ vm.Set("key1", 42)
16+ require.Equal(t, uint64(1), vm.Version())
17+
18+ value, ok := vm.Get("key1")
19+ require.True(t, ok)
20+ require.Equal(t, 42, value)
21+}
22+
23+func TestVersionedMap_Del(t *testing.T) {
24+ t.Parallel()
25+
26+ vm := NewVersionedMap[string, int]()
27+ vm.Set("key1", 42)
28+ initialVersion := vm.Version()
29+
30+ vm.Del("key1")
31+ require.Equal(t, initialVersion+1, vm.Version())
32+
33+ _, ok := vm.Get("key1")
34+ require.False(t, ok)
35+}
36+
37+func TestVersionedMap_VersionIncrement(t *testing.T) {
38+ t.Parallel()
39+
40+ vm := NewVersionedMap[string, int]()
41+ initialVersion := vm.Version()
42+
43+ // Setting a value should increment the version
44+ vm.Set("key1", 42)
45+ require.Equal(t, initialVersion+1, vm.Version())
46+
47+ // Deleting a value should increment the version
48+ vm.Del("key1")
49+ require.Equal(t, initialVersion+2, vm.Version())
50+
51+ // Deleting a non-existent key should still increment the version
52+ vm.Del("nonexistent")
53+ require.Equal(t, initialVersion+3, vm.Version())
54+}
55+
56+func TestVersionedMap_ConcurrentAccess(t *testing.T) {
57+ t.Parallel()
58+
59+ vm := NewVersionedMap[int, int]()
60+ const numGoroutines = 100
61+ const numOperations = 100
62+
63+ // Initial version
64+ initialVersion := vm.Version()
65+
66+ // Perform concurrent Set and Del operations
67+ for i := range numGoroutines {
68+ go func(id int) {
69+ for j := range numOperations {
70+ key := id*numOperations + j
71+ vm.Set(key, key*2)
72+ vm.Del(key)
73+ }
74+ }(i)
75+ }
76+
77+ // Wait for operations to complete by checking the version
78+ // This is a simplified check - in a real test you might want to use sync.WaitGroup
79+ expectedMinVersion := initialVersion + uint64(numGoroutines*numOperations*2)
80+
81+ // Allow some time for operations to complete
82+ for vm.Version() < expectedMinVersion {
83+ // Busy wait - in a real test you'd use proper synchronization
84+ }
85+
86+ // Final version should be at least the expected minimum
87+ require.GreaterOrEqual(t, vm.Version(), expectedMinVersion)
88+ require.Equal(t, 0, vm.Len())
89+}
internal/llm/tools/diagnostics.go+9−74
@@ -16,6 +16,7 @@ import (
16type DiagnosticsParams struct {
16type DiagnosticsParams struct {
17 FilePath string `json:"file_path"`
17 FilePath string `json:"file_path"`
18}
18}
19+
19type diagnosticsTool struct {
20type diagnosticsTool struct {
20 lspClients map[string]*lsp.Client
21 lspClients map[string]*lsp.Client
21}
22}
@@ -76,91 +77,26 @@ func (b *diagnosticsTool) Run(ctx context.Context, call ToolCall) (ToolResponse,
76 }
77 }
77
78
78 lsps := b.lspClients
79 lsps := b.lspClients
79−
80 if len(lsps) == 0 {
80 if len(lsps) == 0 {
81 return NewTextErrorResponse("no LSP clients available"), nil
81 return NewTextErrorResponse("no LSP clients available"), nil
82 }
82 }
83−
83+ notifyLSPs(ctx, lsps, params.FilePath)
84− if params.FilePath != "" {
85− notifyLspOpenFile(ctx, params.FilePath, lsps)
86− waitForLspDiagnostics(ctx, params.FilePath, lsps)
87− }
88−
89 output := getDiagnostics(params.FilePath, lsps)
84 output := getDiagnostics(params.FilePath, lsps)
90−
91 return NewTextResponse(output), nil
85 return NewTextResponse(output), nil
92}
86}
93
87
94−func notifyLspOpenFile(ctx context.Context, filePath string, lsps map[string]*lsp.Client) {
88+func notifyLSPs(ctx context.Context, lsps map[string]*lsp.Client, filepath string) {
95− for _, client := range lsps {
89+ if filepath == "" {
96− err := client.OpenFile(ctx, filePath)
97− if err != nil {
98− continue
99− }
100− }
101−}
102−
103−func waitForLspDiagnostics(ctx context.Context, filePath string, lsps map[string]*lsp.Client) {
104− if len(lsps) == 0 {
105 return
90 return
106 }
91 }
107−
108− diagChan := make(chan struct{}, 1)
109−
110 for _, client := range lsps {
92 for _, client := range lsps {
111− originalDiags := client.GetDiagnostics()
93+ if !client.HandlesFile(filepath) {
112−
94+ continue
113− handler := func(_ context.Context, _ string, params json.RawMessage) {
114− lsp.HandleDiagnostics(client, params)
115− var diagParams protocol.PublishDiagnosticsParams
116− if err := json.Unmarshal(params, &diagParams); err != nil {
117− return
118− }
119−
120− path, err := diagParams.URI.Path()
121− if err != nil {
122− slog.Error("Failed to convert diagnostic URI to path", "uri", diagParams.URI, "error", err)
123− return
124− }
125−
126− if path == filePath || hasDiagnosticsChanged(client.GetDiagnostics(), originalDiags) {
127− select {
128− case diagChan <- struct{}{}:
129− default:
130− }
131− }
132− }
133−
134− client.RegisterNotificationHandler("textDocument/publishDiagnostics", handler)
135−
136− if client.IsFileOpen(filePath) {
137− err := client.NotifyChange(ctx, filePath)
138− if err != nil {
139− continue
140− }
141− } else {
142− err := client.OpenFile(ctx, filePath)
143− if err != nil {
144− continue
145− }
146− }
147− }
148−
149− select {
150− case <-diagChan:
151− case <-time.After(5 * time.Second):
152− case <-ctx.Done():
153− }
154−}
155−
156−func hasDiagnosticsChanged(current, original map[protocol.DocumentURI][]protocol.Diagnostic) bool {
157− for uri, diags := range current {
158− origDiags, exists := original[uri]
159− if !exists || len(diags) != len(origDiags) {
160− return true
161 }
95 }
96+ _ = client.OpenFileOnDemand(ctx, filepath)
97+ _ = client.NotifyChange(ctx, filepath)
98+ client.WaitForDiagnostics(ctx, 5*time.Second)
162 }
99 }
163− return false
164}
100}
165
101
166func getDiagnostics(filePath string, lsps map[string]*lsp.Client) string {
102func getDiagnostics(filePath string, lsps map[string]*lsp.Client) string {
@@ -198,7 +134,6 @@ func getDiagnostics(filePath string, lsps map[string]*lsp.Client) string {
198 fileWarnings := countSeverity(fileDiagnostics, "Warn")
134 fileWarnings := countSeverity(fileDiagnostics, "Warn")
199 projectErrors := countSeverity(projectDiagnostics, "Error")
135 projectErrors := countSeverity(projectDiagnostics, "Error")
200 projectWarnings := countSeverity(projectDiagnostics, "Warn")
136 projectWarnings := countSeverity(projectDiagnostics, "Warn")
201−
202 output.WriteString("\n<diagnostic_summary>\n")
137 output.WriteString("\n<diagnostic_summary>\n")
203 fmt.Fprintf(&output, "Current file: %d errors, %d warnings\n", fileErrors, fileWarnings)
138 fmt.Fprintf(&output, "Current file: %d errors, %d warnings\n", fileErrors, fileWarnings)
204 fmt.Fprintf(&output, "Project: %d errors, %d warnings\n", projectErrors, projectWarnings)
139 fmt.Fprintf(&output, "Project: %d errors, %d warnings\n", projectErrors, projectWarnings)
internal/llm/tools/edit.go+2−1
@@ -184,7 +184,8 @@ func (e *editTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
184 return response, nil
184 return response, nil
185 }
185 }
186
186
187− waitForLspDiagnostics(ctx, params.FilePath, e.lspClients)
187+ notifyLSPs(ctx, e.lspClients, params.FilePath)
188+
188 text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
189 text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
189 text += getDiagnostics(params.FilePath, e.lspClients)
190 text += getDiagnostics(params.FilePath, e.lspClients)
190 response.Content = text
191 response.Content = text
internal/llm/tools/multiedit.go+3−1
@@ -188,8 +188,10 @@ func (m *multiEditTool) Run(ctx context.Context, call ToolCall) (ToolResponse, e
188 return response, nil
188 return response, nil
189 }
189 }
190
190
191+ // Notify LSP clients about the change
192+ notifyLSPs(ctx, m.lspClients, params.FilePath)
193+
191 // Wait for LSP diagnostics and add them to the response
194 // Wait for LSP diagnostics and add them to the response
192− waitForLspDiagnostics(ctx, params.FilePath, m.lspClients)
193 text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
195 text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
194 text += getDiagnostics(params.FilePath, m.lspClients)
196 text += getDiagnostics(params.FilePath, m.lspClients)
195 response.Content = text
197 response.Content = text
internal/llm/tools/view.go+1−1
@@ -233,7 +233,7 @@ func (v *viewTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
233 return ToolResponse{}, fmt.Errorf("error reading file: %w", err)
233 return ToolResponse{}, fmt.Errorf("error reading file: %w", err)
234 }
234 }
235
235
236− notifyLspOpenFile(ctx, filePath, v.lspClients)
236+ notifyLSPs(ctx, v.lspClients, filePath)
237 output := "<file>\n"
237 output := "<file>\n"
238 // Format the output with line numbers
238 // Format the output with line numbers
239 output += addLineNumbers(content, params.Offset+1)
239 output += addLineNumbers(content, params.Offset+1)
internal/llm/tools/write.go+2−1
@@ -221,7 +221,8 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
221
221
222 recordFileWrite(filePath)
222 recordFileWrite(filePath)
223 recordFileRead(filePath)
223 recordFileRead(filePath)
224− waitForLspDiagnostics(ctx, filePath, w.lspClients)
224+
225+ notifyLSPs(ctx, w.lspClients, params.FilePath)
225
226
226 result := fmt.Sprintf("File successfully written: %s", filePath)
227 result := fmt.Sprintf("File successfully written: %s", filePath)
227 result = fmt.Sprintf("<result>\n%s\n</result>", result)
228 result = fmt.Sprintf("<result>\n%s\n</result>", result)
internal/lsp/client.go+24−2
@@ -34,7 +34,7 @@ type Client struct {
34 onDiagnosticsChanged func(name string, count int)
34 onDiagnosticsChanged func(name string, count int)
35
35
36 // Diagnostic cache
36 // Diagnostic cache
37− diagnostics *csync.Map[protocol.DocumentURI, []protocol.Diagnostic]
37+ diagnostics *csync.VersionedMap[protocol.DocumentURI, []protocol.Diagnostic]
38
38
39 // Files are currently opened by the LSP
39 // Files are currently opened by the LSP
40 openFiles *csync.Map[string, *OpenFileInfo]
40 openFiles *csync.Map[string, *OpenFileInfo]
@@ -83,7 +83,7 @@ func New(ctx context.Context, name string, config config.LSPConfig) (*Client, er
83 client: powernapClient,
83 client: powernapClient,
84 name: name,
84 name: name,
85 fileTypes: config.FileTypes,
85 fileTypes: config.FileTypes,
86− diagnostics: csync.NewMap[protocol.DocumentURI, []protocol.Diagnostic](),
86+ diagnostics: csync.NewVersionedMap[protocol.DocumentURI, []protocol.Diagnostic](),
87 openFiles: csync.NewMap[string, *OpenFileInfo](),
87 openFiles: csync.NewMap[string, *OpenFileInfo](),
88 config: config,
88 config: config,
89 }
89 }
@@ -314,6 +314,8 @@ func (c *Client) NotifyChange(ctx context.Context, filepath string) error {
314}
314}
315
315
316// CloseFile closes a file in the LSP server.
316// CloseFile closes a file in the LSP server.
317+//
318+// NOTE: this is only ever called on LSP shutdown.
317func (c *Client) CloseFile(ctx context.Context, filepath string) error {
319func (c *Client) CloseFile(ctx context.Context, filepath string) error {
318 cfg := config.Get()
320 cfg := config.Get()
319 uri := string(protocol.URIFromPath(filepath))
321 uri := string(protocol.URIFromPath(filepath))
@@ -454,6 +456,26 @@ func (c *Client) openKeyConfigFiles(ctx context.Context) {
454 }
456 }
455}
457}
456
458
459+// WaitForDiagnostics waits until diagnostics change or the timeout is reached.
460+func (c *Client) WaitForDiagnostics(ctx context.Context, d time.Duration) {
461+ ticker := time.NewTicker(200 * time.Millisecond)
462+ defer ticker.Stop()
463+ timeout := time.After(d)
464+ pv := c.diagnostics.Version()
465+ for {
466+ select {
467+ case <-ctx.Done():
468+ return
469+ case <-timeout:
470+ return
471+ case <-ticker.C:
472+ if pv != c.diagnostics.Version() {
473+ return
474+ }
475+ }
476+ }
477+}
478+
457// HasRootMarkers checks if any of the specified root marker patterns exist in the given directory.
479// HasRootMarkers checks if any of the specified root marker patterns exist in the given directory.
458// Uses glob patterns to match files, allowing for more flexible matching.
480// Uses glob patterns to match files, allowing for more flexible matching.
459func HasRootMarkers(dir string, rootMarkers []string) bool {
481func HasRootMarkers(dir string, rootMarkers []string) bool {
internal/lsp/watcher/global_watcher.godeleted−394
@@ -1,394 +0,0 @@
1−package watcher
2−
3−import (
4− "context"
5− "errors"
6− "fmt"
7− "log/slog"
8− "os"
9− "path/filepath"
10− "sync"
11− "sync/atomic"
12− "syscall"
13− "time"
14−
15− "github.com/charmbracelet/crush/internal/config"
16− "github.com/charmbracelet/crush/internal/csync"
17− "github.com/charmbracelet/crush/internal/fsext"
18− "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
19− "github.com/raphamorim/notify"
20−)
21−
22−// global manages file watching shared across all LSP clients.
23−//
24−// IMPORTANT: This implementation uses github.com/raphamorim/notify which provides
25−// recursive watching on all platforms. On macOS it uses FSEvents, on Linux it
26−// uses inotify (with recursion handled by the library), and on Windows it uses
27−// ReadDirectoryChangesW.
28−//
29−// Key benefits:
30−// - Single watch point for entire directory tree
31−// - Automatic recursive watching without manually adding subdirectories
32−// - No file descriptor exhaustion issues
33−// - Built-in ignore system for filtering file events
34−type global struct {
35− // Channel for receiving file system events
36− events chan notify.EventInfo
37−
38− // Map of workspace watchers by client name
39− watchers *csync.Map[string, *Client]
40−
41− // Single workspace root directory for ignore checking
42− root string
43−
44− started atomic.Bool
45−
46− // Debouncing for file events (shared across all clients)
47− debounceTime time.Duration
48− debounceMap *csync.Map[string, *time.Timer]
49−
50− // Context for shutdown
51− ctx context.Context
52− cancel context.CancelFunc
53−
54− // Wait group for cleanup
55− wg sync.WaitGroup
56−}
57−
58−// instance returns the singleton global watcher instance
59−var instance = sync.OnceValue(func() *global {
60− ctx, cancel := context.WithCancel(context.Background())
61− gw := &global{
62− events: make(chan notify.EventInfo, 4096), // Large buffer to prevent dropping events
63− watchers: csync.NewMap[string, *Client](),
64− debounceTime: 300 * time.Millisecond,
65− debounceMap: csync.NewMap[string, *time.Timer](),
66− ctx: ctx,
67− cancel: cancel,
68− }
69−
70− return gw
71−})
72−
73−// register registers a workspace watcher with the global watcher
74−func (gw *global) register(name string, watcher *Client) {
75− gw.watchers.Set(name, watcher)
76− slog.Debug("lsp watcher: Registered workspace watcher", "name", name)
77−}
78−
79−// unregister removes a workspace watcher from the global watcher
80−func (gw *global) unregister(name string) {
81− gw.watchers.Del(name)
82− slog.Debug("lsp watcher: Unregistered workspace watcher", "name", name)
83−}
84−
85−// Start sets up recursive watching on the workspace root.
86−//
87−// Note: We use github.com/raphamorim/notify which provides recursive watching
88−// with a single watch point. The "..." suffix means watch recursively.
89−// This is much more efficient than manually walking and watching each directory.
90−func Start() error {
91− gw := instance()
92−
93− // technically workspace root is always the same...
94− if gw.started.Load() {
95− slog.Debug("lsp watcher: watcher already set up, skipping")
96− return nil
97− }
98−
99− cfg := config.Get()
100− root := cfg.WorkingDir()
101− slog.Debug("lsp watcher: set workspace directory to global watcher", "path", root)
102−
103− // Store the workspace root for hierarchical ignore checking
104− gw.root = root
105− gw.started.Store(true)
106−
107− // Set up ignore system
108− if err := setupIgnoreSystem(root); err != nil {
109− slog.Warn("lsp watcher: Failed to set up ignore system", "error", err)
110− // Continue anyway, but without ignore functionality
111− }
112−
113− // Start the event processing goroutine
114− gw.wg.Add(1)
115− go gw.processEvents()
116−
117− // Set up recursive watching on the root directory
118− // The "..." suffix tells notify to watch recursively
119− watchPath := filepath.Join(root, "...")
120−
121− // Watch for all event types we care about
122− events := notify.Create | notify.Write | notify.Remove | notify.Rename
123−
124− if err := notify.Watch(watchPath, gw.events, events); err != nil {
125− // Check if the error might be due to file descriptor limits
126− if isFileLimitError(err) {
127− slog.Warn("lsp watcher: Hit file descriptor limit, attempting to increase", "error", err)
128− if newLimit, rlimitErr := maximizeOpenFileLimit(); rlimitErr == nil {
129− slog.Info("lsp watcher: Increased file descriptor limit", "limit", newLimit)
130− // Retry the watch operation
131− if err = notify.Watch(watchPath, gw.events, events); err == nil {
132− slog.Info("lsp watcher: Successfully set up watch after increasing limit")
133− goto watchSuccess
134− }
135− err = fmt.Errorf("still failed after increasing limit: %w", err)
136− } else {
137− slog.Warn("lsp watcher: Failed to increase file descriptor limit", "error", rlimitErr)
138− }
139− }
140− return fmt.Errorf("lsp watcher: error setting up recursive watch on %s: %w", root, err)
141− }
142−watchSuccess:
143−
144− slog.Info("lsp watcher: Started recursive watching", "root", root)
145− return nil
146−}
147−
148−// processEvents processes file system events from the notify library.
149−// Since notify handles recursive watching for us, we don't need to manually
150−// add new directories - they're automatically included.
151−func (gw *global) processEvents() {
152− defer gw.wg.Done()
153− cfg := config.Get()
154−
155− if !gw.started.Load() {
156− slog.Error("lsp watcher: Global watcher not initialized")
157− return
158− }
159−
160− for {
161− select {
162− case <-gw.ctx.Done():
163− return
164−
165− case event, ok := <-gw.events:
166− if !ok {
167− return
168− }
169−
170− path := event.Path()
171−
172− if cfg != nil && cfg.Options.DebugLSP {
173− slog.Debug("lsp watcher: Global watcher received event", "path", path, "event", event.Event().String())
174− }
175−
176− // Convert notify event to our internal format and handle it
177− gw.handleFileEvent(event)
178− }
179− }
180−}
181−
182−// handleFileEvent processes a file system event and distributes notifications to relevant clients
183−func (gw *global) handleFileEvent(event notify.EventInfo) {
184− cfg := config.Get()
185− path := event.Path()
186− uri := string(protocol.URIFromPath(path))
187−
188− // Map notify events to our change types
189− var changeType protocol.FileChangeType
190− var watchKindNeeded protocol.WatchKind
191−
192− switch event.Event() {
193− case notify.Create:
194− changeType = protocol.FileChangeType(protocol.Created)
195− watchKindNeeded = protocol.WatchCreate
196− // Handle file creation for all relevant clients
197− if !isDir(path) && !fsext.ShouldExcludeFile(gw.root, path) {
198− gw.openMatchingFileForClients(path)
199− }
200− case notify.Write:
201− changeType = protocol.FileChangeType(protocol.Changed)
202− watchKindNeeded = protocol.WatchChange
203− case notify.Remove:
204− changeType = protocol.FileChangeType(protocol.Deleted)
205− watchKindNeeded = protocol.WatchDelete
206− case notify.Rename:
207− // Treat rename as delete + create
208− // First handle as delete
209− for _, watcher := range gw.watchers.Seq2() {
210− if !watcher.client.HandlesFile(path) {
211− continue
212− }
213− if watched, watchKind := watcher.isPathWatched(path); watched {
214− if watchKind&protocol.WatchDelete != 0 {
215− gw.handleFileEventForClient(watcher, uri, protocol.FileChangeType(protocol.Deleted))
216− }
217− }
218− }
219− // Then check if renamed file exists and treat as create
220− if !isDir(path) {
221− changeType = protocol.FileChangeType(protocol.Created)
222− watchKindNeeded = protocol.WatchCreate
223− } else {
224− return // Already handled delete, nothing more to do for directories
225− }
226− default:
227− // Unknown event type, skip
228− return
229− }
230−
231− // Process the event for each relevant client
232− for client, watcher := range gw.watchers.Seq2() {
233− if !watcher.client.HandlesFile(path) {
234− continue // client doesn't handle this filetype
235− }
236−
237− // Debug logging per client
238− if cfg.Options.DebugLSP {
239− matched, kind := watcher.isPathWatched(path)
240− slog.Debug("lsp watcher: File event for client",
241− "path", path,
242− "event", event.Event().String(),
243− "watched", matched,
244− "kind", kind,
245− "client", client,
246− )
247− }
248−
249− // Check if this path should be watched according to server registrations
250− if watched, watchKind := watcher.isPathWatched(path); watched {
251− if watchKind&watchKindNeeded != 0 {
252− // Skip directory events for non-delete operations
253− if changeType != protocol.FileChangeType(protocol.Deleted) && isDir(path) {
254− continue
255− }
256−
257− if changeType == protocol.FileChangeType(protocol.Deleted) {
258− // Don't debounce deletes
259− gw.handleFileEventForClient(watcher, uri, changeType)
260− } else {
261− // Debounce creates and changes
262− gw.debounceHandleFileEventForClient(watcher, uri, changeType)
263− }
264− }
265− }
266− }
267−}
268−
269−// isDir checks if a path is a directory
270−func isDir(path string) bool {
271− info, err := os.Stat(path)
272− return err == nil && info.IsDir()
273−}
274−
275−// openMatchingFileForClients opens a newly created file for all clients that handle it (only once per file)
276−func (gw *global) openMatchingFileForClients(path string) {
277− // Skip directories
278− info, err := os.Stat(path)
279− if err != nil || info.IsDir() {
280− return
281− }
282−
283− // Skip excluded files
284− if fsext.ShouldExcludeFile(gw.root, path) {
285− return
286− }
287−
288− // Open the file for each client that handles it and has matching patterns
289− for _, watcher := range gw.watchers.Seq2() {
290− if watcher.client.HandlesFile(path) {
291− watcher.openMatchingFile(gw.ctx, path)
292− }
293− }
294−}
295−
296−// debounceHandleFileEventForClient handles file events with debouncing for a specific client
297−func (gw *global) debounceHandleFileEventForClient(watcher *Client, uri string, changeType protocol.FileChangeType) {
298− // Create a unique key based on URI, change type, and client name
299− key := fmt.Sprintf("%s:%d:%s", uri, changeType, watcher.name)
300−
301− // Cancel existing timer if any
302− if timer, exists := gw.debounceMap.Get(key); exists {
303− timer.Stop()
304− }
305−
306− // Create new timer
307− gw.debounceMap.Set(key, time.AfterFunc(gw.debounceTime, func() {
308− gw.handleFileEventForClient(watcher, uri, changeType)
309−
310− // Cleanup timer after execution
311− gw.debounceMap.Del(key)
312− }))
313−}
314−
315−// handleFileEventForClient sends file change notifications to a specific client
316−func (gw *global) handleFileEventForClient(watcher *Client, uri string, changeType protocol.FileChangeType) {
317− // If the file is open and it's a change event, use didChange notification
318− filePath, err := protocol.DocumentURI(uri).Path()
319− if err != nil {
320− slog.Error("lsp watcher: Error converting URI to path", "uri", uri, "error", err)
321− return
322− }
323−
324− if changeType == protocol.FileChangeType(protocol.Deleted) {
325− watcher.client.ClearDiagnosticsForURI(protocol.DocumentURI(uri))
326− } else if changeType == protocol.FileChangeType(protocol.Changed) && watcher.client.IsFileOpen(filePath) {
327− err := watcher.client.NotifyChange(gw.ctx, filePath)
328− if err != nil {
329− slog.Error("lsp watcher: Error notifying change", "error", err)
330− }
331− return
332− }
333−
334− // Notify LSP server about the file event using didChangeWatchedFiles
335− if err := watcher.notifyFileEvent(gw.ctx, uri, changeType); err != nil {
336− slog.Error("lsp watcher: Error notifying LSP server about file event", "error", err)
337− }
338−}
339−
340−// shutdown gracefully shuts down the global watcher
341−func (gw *global) shutdown() {
342− if gw.cancel != nil {
343− gw.cancel()
344− }
345−
346− // Stop watching and close the event channel
347− notify.Stop(gw.events)
348− close(gw.events)
349−
350− gw.wg.Wait()
351− slog.Debug("lsp watcher: Global watcher shutdown complete")
352−}
353−
354−// Shutdown shuts down the singleton global watcher
355−func Shutdown() {
356− instance().shutdown()
357−}
358−
359−// isFileLimitError checks if an error is related to file descriptor limits
360−func isFileLimitError(err error) bool {
361− if err == nil {
362− return false
363− }
364− // Check for common file limit errors
365− return errors.Is(err, syscall.EMFILE) || errors.Is(err, syscall.ENFILE)
366−}
367−
368−// setupIgnoreSystem configures the notify library's ignore system
369−// to use .crushignore and .gitignore files for filtering file events
370−func setupIgnoreSystem(root string) error {
371− // Create a new ignore matcher for the workspace root
372− im := notify.NewIgnoreMatcher(root)
373−
374− // Load .crushignore file if it exists
375− crushignorePath := filepath.Join(root, ".crushignore")
376− if _, err := os.Stat(crushignorePath); err == nil {
377− if err := im.LoadIgnoreFile(crushignorePath); err != nil {
378− slog.Warn("lsp watcher: Failed to load .crushignore file", "error", err)
379− }
380− }
381−
382− // Load .gitignore file if it exists
383− gitignorePath := filepath.Join(root, ".gitignore")
384− if _, err := os.Stat(gitignorePath); err == nil {
385− if err := im.LoadIgnoreFile(gitignorePath); err != nil {
386− slog.Warn("lsp watcher: Failed to load .gitignore file", "error", err)
387− }
388− }
389−
390− // Set as the global ignore matcher
391− notify.SetIgnoreMatcher(im)
392−
393− return nil
394−}
internal/lsp/watcher/global_watcher_test.godeleted−302
@@ -1,302 +0,0 @@
1−package watcher
2−
3−import (
4− "context"
5− "os"
6− "path/filepath"
7− "testing"
8− "time"
9−
10− "github.com/charmbracelet/crush/internal/csync"
11− "github.com/raphamorim/notify"
12−)
13−
14−func TestGlobalWatcher(t *testing.T) {
15− t.Parallel()
16−
17− // Test that we can get the global watcher instance
18− gw1 := instance()
19− if gw1 == nil {
20− t.Fatal("Expected global watcher instance, got nil")
21− }
22−
23− // Test that subsequent calls return the same instance (singleton)
24− gw2 := instance()
25− if gw1 != gw2 {
26− t.Fatal("Expected same global watcher instance, got different instances")
27− }
28−
29− // Test registration and unregistration
30− mockWatcher := &Client{
31− name: "test-watcher",
32− }
33−
34− gw1.register("test", mockWatcher)
35−
36− // Check that it was registered
37− registered, _ := gw1.watchers.Get("test")
38−
39− if registered != mockWatcher {
40− t.Fatal("Expected workspace watcher to be registered")
41− }
42−
43− // Test unregistration
44− gw1.unregister("test")
45−
46− unregistered, _ := gw1.watchers.Get("test")
47−
48− if unregistered != nil {
49− t.Fatal("Expected workspace watcher to be unregistered")
50− }
51−}
52−
53−func TestGlobalWatcherWorkspaceIdempotent(t *testing.T) {
54− t.Parallel()
55−
56− // Create a temporary directory for testing
57− tempDir := t.TempDir()
58−
59− // Create a new global watcher instance for this test
60− ctx, cancel := context.WithCancel(context.Background())
61− defer cancel()
62−
63− gw := &global{
64− events: make(chan notify.EventInfo, 100),
65− watchers: csync.NewMap[string, *Client](),
66− debounceTime: 300 * time.Millisecond,
67− debounceMap: csync.NewMap[string, *time.Timer](),
68− ctx: ctx,
69− cancel: cancel,
70− }
71−
72− // Test that watching the same workspace multiple times is safe (idempotent)
73− // With notify, we use recursive watching with "..."
74− watchPath := filepath.Join(tempDir, "...")
75−
76− err1 := notify.Watch(watchPath, gw.events, notify.All)
77− if err1 != nil {
78− t.Fatalf("First Watch call failed: %v", err1)
79− }
80− defer notify.Stop(gw.events)
81−
82− // Watching the same path again should be safe (notify handles this)
83− err2 := notify.Watch(watchPath, gw.events, notify.All)
84− if err2 != nil {
85− t.Fatalf("Second Watch call failed: %v", err2)
86− }
87−
88− err3 := notify.Watch(watchPath, gw.events, notify.All)
89− if err3 != nil {
90− t.Fatalf("Third Watch call failed: %v", err3)
91− }
92−
93− // All calls should succeed - notify handles deduplication internally
94− // This test verifies that multiple Watch calls are safe
95−}
96−
97−func TestGlobalWatcherRecursiveWatching(t *testing.T) {
98− t.Parallel()
99−
100− // Create a temporary directory structure for testing
101− tempDir := t.TempDir()
102− subDir := filepath.Join(tempDir, "subdir")
103− if err := os.Mkdir(subDir, 0o755); err != nil {
104− t.Fatalf("Failed to create subdirectory: %v", err)
105− }
106−
107− // Create some files
108− file1 := filepath.Join(tempDir, "file1.txt")
109− file2 := filepath.Join(subDir, "file2.txt")
110− if err := os.WriteFile(file1, []byte("content1"), 0o644); err != nil {
111− t.Fatalf("Failed to create file1: %v", err)
112− }
113− if err := os.WriteFile(file2, []byte("content2"), 0o644); err != nil {
114− t.Fatalf("Failed to create file2: %v", err)
115− }
116−
117− // Create a new global watcher instance for this test
118− ctx, cancel := context.WithCancel(context.Background())
119− defer cancel()
120−
121− gw := &global{
122− events: make(chan notify.EventInfo, 100),
123− watchers: csync.NewMap[string, *Client](),
124− debounceTime: 300 * time.Millisecond,
125− debounceMap: csync.NewMap[string, *time.Timer](),
126− ctx: ctx,
127− cancel: cancel,
128− root: tempDir,
129− }
130−
131− // Set up recursive watching on the root directory
132− watchPath := filepath.Join(tempDir, "...")
133− if err := notify.Watch(watchPath, gw.events, notify.All); err != nil {
134− t.Fatalf("Failed to set up recursive watch: %v", err)
135− }
136− defer notify.Stop(gw.events)
137−
138− // Verify that our expected directories and files exist
139− expectedDirs := []string{tempDir, subDir}
140−
141− for _, expectedDir := range expectedDirs {
142− info, err := os.Stat(expectedDir)
143− if err != nil {
144− t.Fatalf("Expected directory %s doesn't exist: %v", expectedDir, err)
145− }
146− if !info.IsDir() {
147− t.Fatalf("Expected %s to be a directory, but it's not", expectedDir)
148− }
149− }
150−
151− // Verify that files exist
152− testFiles := []string{file1, file2}
153− for _, file := range testFiles {
154− info, err := os.Stat(file)
155− if err != nil {
156− t.Fatalf("Test file %s doesn't exist: %v", file, err)
157− }
158− if info.IsDir() {
159− t.Fatalf("Expected %s to be a file, but it's a directory", file)
160− }
161− }
162−
163− // Create a new file in the subdirectory to test recursive watching
164− newFile := filepath.Join(subDir, "new.txt")
165− if err := os.WriteFile(newFile, []byte("new content"), 0o644); err != nil {
166− t.Fatalf("Failed to create new file: %v", err)
167− }
168−
169− // We should receive an event for the file creation
170− select {
171− case event := <-gw.events:
172− // On macOS, paths might have /private prefix, so we need to compare the real paths
173− eventPath, _ := filepath.EvalSymlinks(event.Path())
174− expectedPath, _ := filepath.EvalSymlinks(newFile)
175− if eventPath != expectedPath {
176− // Also try comparing just the base names as a fallback
177− if filepath.Base(event.Path()) != filepath.Base(newFile) {
178− t.Errorf("Expected event for %s, got %s", newFile, event.Path())
179− }
180− }
181− case <-time.After(2 * time.Second):
182− t.Fatal("Timeout waiting for file creation event")
183− }
184−}
185−
186−func TestNotifyDeduplication(t *testing.T) {
187− t.Parallel()
188−
189− // Create a temporary directory for testing
190− tempDir := t.TempDir()
191−
192− // Create an event channel
193− events := make(chan notify.EventInfo, 100)
194− defer close(events)
195−
196− // Add the same directory multiple times with recursive watching
197− watchPath := filepath.Join(tempDir, "...")
198−
199− err1 := notify.Watch(watchPath, events, notify.All)
200− if err1 != nil {
201− t.Fatalf("First Watch failed: %v", err1)
202− }
203− defer notify.Stop(events)
204−
205− err2 := notify.Watch(watchPath, events, notify.All)
206− if err2 != nil {
207− t.Fatalf("Second Watch failed: %v", err2)
208− }
209−
210− err3 := notify.Watch(watchPath, events, notify.All)
211− if err3 != nil {
212− t.Fatalf("Third Watch failed: %v", err3)
213− }
214−
215− // All should succeed - notify handles deduplication internally
216− // This test verifies the notify behavior we're relying on
217−}
218−
219−func TestGlobalWatcherRespectsIgnoreFiles(t *testing.T) {
220− t.Parallel()
221−
222− // Create a temporary directory structure for testing
223− tempDir := t.TempDir()
224−
225− // Create directories that should be ignored
226− nodeModules := filepath.Join(tempDir, "node_modules")
227− target := filepath.Join(tempDir, "target")
228− customIgnored := filepath.Join(tempDir, "custom_ignored")
229− normalDir := filepath.Join(tempDir, "src")
230−
231− for _, dir := range []string{nodeModules, target, customIgnored, normalDir} {
232− if err := os.MkdirAll(dir, 0o755); err != nil {
233− t.Fatalf("Failed to create directory %s: %v", dir, err)
234− }
235− }
236−
237− // Create .gitignore file
238− gitignoreContent := "node_modules/\ntarget/\n"
239− if err := os.WriteFile(filepath.Join(tempDir, ".gitignore"), []byte(gitignoreContent), 0o644); err != nil {
240− t.Fatalf("Failed to create .gitignore: %v", err)
241− }
242−
243− // Create .crushignore file
244− crushignoreContent := "custom_ignored/\n"
245− if err := os.WriteFile(filepath.Join(tempDir, ".crushignore"), []byte(crushignoreContent), 0o644); err != nil {
246− t.Fatalf("Failed to create .crushignore: %v", err)
247− }
248−
249− // Create a new global watcher instance for this test
250− ctx, cancel := context.WithCancel(context.Background())
251− defer cancel()
252−
253− gw := &global{
254− events: make(chan notify.EventInfo, 100),
255− watchers: csync.NewMap[string, *Client](),
256− debounceTime: 300 * time.Millisecond,
257− debounceMap: csync.NewMap[string, *time.Timer](),
258− ctx: ctx,
259− cancel: cancel,
260− root: tempDir,
261− }
262−
263− // Set up recursive watching
264− watchPath := filepath.Join(tempDir, "...")
265− if err := notify.Watch(watchPath, gw.events, notify.All); err != nil {
266− t.Fatalf("Failed to set up recursive watch: %v", err)
267− }
268− defer notify.Stop(gw.events)
269−
270− // The notify library watches everything, but our processEvents
271− // function should filter out ignored files using fsext.ShouldExcludeFile
272− // This test verifies that the structure is set up correctly
273−}
274−
275−func TestGlobalWatcherShutdown(t *testing.T) {
276− t.Parallel()
277−
278− // Create a new context for this test
279− ctx, cancel := context.WithCancel(context.Background())
280− defer cancel()
281−
282− // Create a temporary global watcher for testing
283− gw := &global{
284− events: make(chan notify.EventInfo, 100),
285− watchers: csync.NewMap[string, *Client](),
286− debounceTime: 300 * time.Millisecond,
287− debounceMap: csync.NewMap[string, *time.Timer](),
288− ctx: ctx,
289− cancel: cancel,
290− }
291−
292− // Test shutdown doesn't panic
293− gw.shutdown()
294−
295− // Verify context was cancelled
296− select {
297− case <-gw.ctx.Done():
298− // Expected
299− case <-time.After(100 * time.Millisecond):
300− t.Fatal("Expected context to be cancelled after shutdown")
301− }
302−}
internal/lsp/watcher/rlimit_stub.godeleted−12
@@ -1,12 +0,0 @@
1−//go:build !unix
2−
3−package watcher
4−
5−// maximizeOpenFileLimit is a no-op on non-Unix systems.
6−// Returns a high value to indicate no practical limit.
7−func maximizeOpenFileLimit() (int, error) {
8− // Windows and other non-Unix systems don't have file descriptor limits
9− // in the same way Unix systems do. Return a very high value to indicate
10− // there's no practical limit to worry about.
11− return 10000000, nil // 10M handles - way more than any process would use
12−}
internal/lsp/watcher/rlimit_unix.godeleted−57
@@ -1,57 +0,0 @@
1−//go:build unix
2−
3−// This file contains code inspired by Syncthing's rlimit implementation
4−// Syncthing is licensed under the Mozilla Public License Version 2.0
5−// See: https://github.com/syncthing/syncthing/blob/main/LICENSE
6−
7−package watcher
8−
9−import (
10− "runtime"
11− "syscall"
12−)
13−
14−const (
15− // macOS has a specific limit for RLIMIT_NOFILE
16− darwinOpenMax = 10240
17−)
18−
19−// maximizeOpenFileLimit tries to set the resource limit RLIMIT_NOFILE (number
20−// of open file descriptors) to the max (hard limit), if the current (soft
21−// limit) is below the max. Returns the new (though possibly unchanged) limit,
22−// or an error if it could not be changed.
23−func maximizeOpenFileLimit() (int, error) {
24− // Get the current limit on number of open files.
25− var lim syscall.Rlimit
26− if err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &lim); err != nil {
27− return 0, err
28− }
29−
30− // If we're already at max, there's no need to try to raise the limit.
31− if lim.Cur >= lim.Max {
32− return int(lim.Cur), nil
33− }
34−
35− // macOS doesn't like a soft limit greater than OPEN_MAX
36− if runtime.GOOS == "darwin" && lim.Max > darwinOpenMax {
37− lim.Max = darwinOpenMax
38− }
39−
40− // Try to increase the limit to the max.
41− oldLimit := lim.Cur
42− lim.Cur = lim.Max
43− if err := syscall.Setrlimit(syscall.RLIMIT_NOFILE, &lim); err != nil {
44− return int(oldLimit), err
45− }
46−
47− // If the set succeeded, perform a new get to see what happened. We might
48− // have gotten a value lower than the one in lim.Max, if lim.Max was
49− // something that indicated "unlimited" (i.e. intmax).
50− if err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &lim); err != nil {
51− // We don't really know the correct value here since Getrlimit
52− // mysteriously failed after working once... Shouldn't ever happen.
53− return 0, err
54− }
55−
56− return int(lim.Cur), nil
57−}
internal/lsp/watcher/watcher.godeleted−548
@@ -1,548 +0,0 @@
1−package watcher
2−
3−import (
4− "context"
5− "fmt"
6− "log/slog"
7− "os"
8− "path/filepath"
9− "strings"
10− "time"
11−
12− "github.com/bmatcuk/doublestar/v4"
13− "github.com/charmbracelet/crush/internal/config"
14− "github.com/charmbracelet/crush/internal/csync"
15− "github.com/charmbracelet/crush/internal/lsp"
16− "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
17−)
18−
19−// Client manages LSP file watching for a specific client
20−// It now delegates actual file watching to the GlobalWatcher
21−type Client struct {
22− client *lsp.Client
23− name string
24− workspacePath string
25−
26− // File watchers registered by the server
27− registrations *csync.Slice[protocol.FileSystemWatcher]
28−}
29−
30−// New creates a new workspace watcher for the given client.
31−func New(name string, client *lsp.Client) *Client {
32− return &Client{
33− name: name,
34− client: client,
35− registrations: csync.NewSlice[protocol.FileSystemWatcher](),
36− }
37−}
38−
39−// register adds file watchers to track
40−func (w *Client) register(ctx context.Context, id string, watchers []protocol.FileSystemWatcher) {
41− cfg := config.Get()
42−
43− w.registrations.Append(watchers...)
44−
45− if cfg.Options.DebugLSP {
46− slog.Debug("Adding file watcher registrations",
47− "id", id,
48− "watchers", len(watchers),
49− "total", w.registrations.Len(),
50− )
51−
52− for i, watcher := range watchers {
53− slog.Debug("Registration", "index", i+1)
54−
55− // Log the GlobPattern
56− switch v := watcher.GlobPattern.Value.(type) {
57− case string:
58− slog.Debug("GlobPattern", "pattern", v)
59− case protocol.RelativePattern:
60− slog.Debug("GlobPattern", "pattern", v.Pattern)
61−
62− // Log BaseURI details
63− switch u := v.BaseURI.Value.(type) {
64− case string:
65− slog.Debug("BaseURI", "baseURI", u)
66− case protocol.DocumentURI:
67− slog.Debug("BaseURI", "baseURI", u)
68− default:
69− slog.Debug("BaseURI", "baseURI", u)
70− }
71− default:
72− slog.Debug("GlobPattern unknown type", "type", fmt.Sprintf("%T", v))
73− }
74−
75− // Log WatchKind
76− watchKind := protocol.WatchKind(protocol.WatchChange | protocol.WatchCreate | protocol.WatchDelete)
77− if watcher.Kind != nil {
78− watchKind = *watcher.Kind
79− }
80−
81− slog.Debug("WatchKind", "kind", watchKind)
82− }
83− }
84−
85− // For servers that need file preloading, open high-priority files only
86− if shouldPreloadFiles(w.name) {
87− go func() {
88− highPriorityFilesOpened := w.openHighPriorityFiles(ctx, w.name)
89− if cfg.Options.DebugLSP {
90− slog.Debug("Opened high-priority files",
91− "count", highPriorityFilesOpened,
92− "serverName", w.name)
93− }
94− }()
95− }
96−}
97−
98−// openHighPriorityFiles opens important files for the server type
99−// Returns the number of files opened
100−func (w *Client) openHighPriorityFiles(ctx context.Context, serverName string) int {
101− cfg := config.Get()
102− filesOpened := 0
103−
104− // Define patterns for high-priority files based on server type
105− var patterns []string
106−
107− // TODO: move this to LSP config
108− switch serverName {
109− case "typescript", "typescript-language-server", "tsserver", "vtsls":
110− patterns = []string{
111− "**/tsconfig.json",
112− "**/package.json",
113− "**/jsconfig.json",
114− "**/index.ts",
115− "**/index.js",
116− "**/main.ts",
117− "**/main.js",
118− }
119− case "gopls":
120− patterns = []string{
121− "**/go.mod",
122− "**/go.sum",
123− "**/main.go",
124− }
125− case "rust-analyzer":
126− patterns = []string{
127− "**/Cargo.toml",
128− "**/Cargo.lock",
129− "**/src/lib.rs",
130− "**/src/main.rs",
131− }
132− case "python", "pyright", "pylsp":
133− patterns = []string{
134− "**/pyproject.toml",
135− "**/setup.py",
136− "**/requirements.txt",
137− "**/__init__.py",
138− "**/__main__.py",
139− }
140− case "clangd":
141− patterns = []string{
142− "**/CMakeLists.txt",
143− "**/Makefile",
144− "**/compile_commands.json",
145− }
146− case "java", "jdtls":
147− patterns = []string{
148− "**/pom.xml",
149− "**/build.gradle",
150− "**/src/main/java/**/*.java",
151− }
152− default:
153− // For unknown servers, use common configuration files
154− patterns = []string{
155− "**/package.json",
156− "**/Makefile",
157− "**/CMakeLists.txt",
158− "**/.editorconfig",
159− }
160− }
161−
162− // Collect all files to open first
163− var filesToOpen []string
164−
165− // For each pattern, find matching files
166− for _, pattern := range patterns {
167− // Use doublestar.Glob to find files matching the pattern (supports ** patterns)
168− matches, err := doublestar.Glob(os.DirFS(w.workspacePath), pattern)
169− if err != nil {
170− if cfg.Options.DebugLSP {
171− slog.Debug("Error finding high-priority files", "pattern", pattern, "error", err)
172− }
173− continue
174− }
175−
176− for _, match := range matches {
177− // Convert relative path to absolute
178− fullPath := filepath.Join(w.workspacePath, match)
179−
180− // Skip directories and excluded files
181− info, err := os.Stat(fullPath)
182− if err != nil || info.IsDir() || shouldExcludeFile(fullPath) {
183− continue
184− }
185−
186− filesToOpen = append(filesToOpen, fullPath)
187−
188− // Limit the number of files per pattern
189− if len(filesToOpen) >= 5 && (serverName != "java" && serverName != "jdtls") {
190− break
191− }
192− }
193− }
194−
195− // Open files in batches to reduce overhead
196− batchSize := 3
197− for i := 0; i < len(filesToOpen); i += batchSize {
198− end := min(i+batchSize, len(filesToOpen))
199−
200− // Open batch of files
201− for j := i; j < end; j++ {
202− fullPath := filesToOpen[j]
203− if err := w.client.OpenFile(ctx, fullPath); err != nil {
204− if cfg.Options.DebugLSP {
205− slog.Debug("Error opening high-priority file", "path", fullPath, "error", err)
206− }
207− } else {
208− filesOpened++
209− if cfg.Options.DebugLSP {
210− slog.Debug("Opened high-priority file", "path", fullPath)
211− }
212− }
213− }
214−
215− // Only add delay between batches, not individual files
216− if end < len(filesToOpen) {
217− time.Sleep(50 * time.Millisecond)
218− }
219− }
220−
221− return filesOpened
222−}
223−
224−// Watch sets up file watching for a workspace using the global watcher
225−func (w *Client) Watch(ctx context.Context, workspacePath string) {
226− w.workspacePath = workspacePath
227−
228− slog.Debug("Starting workspace watcher", "workspacePath", workspacePath, "serverName", w.name)
229−
230− // Register this workspace watcher with the global watcher
231− instance().register(w.name, w)
232− defer instance().unregister(w.name)
233−
234− // Register handler for file watcher registrations from the server
235− lsp.RegisterFileWatchHandler(func(id string, watchers []protocol.FileSystemWatcher) {
236− w.register(ctx, id, watchers)
237− })
238−
239− // Wait for context cancellation
240− <-ctx.Done()
241− slog.Debug("Workspace watcher stopped", "name", w.name)
242−}
243−
244−// isPathWatched checks if a path should be watched based on server registrations
245−// If no explicit registrations, watch everything
246−func (w *Client) isPathWatched(path string) (bool, protocol.WatchKind) {
247− if w.registrations.Len() == 0 {
248− return true, protocol.WatchKind(protocol.WatchChange | protocol.WatchCreate | protocol.WatchDelete)
249− }
250−
251− // Check each registration
252− for reg := range w.registrations.Seq() {
253− isMatch := w.matchesPattern(path, reg.GlobPattern)
254− if isMatch {
255− kind := protocol.WatchKind(protocol.WatchChange | protocol.WatchCreate | protocol.WatchDelete)
256− if reg.Kind != nil {
257− kind = *reg.Kind
258− }
259− return true, kind
260− }
261− }
262−
263− return false, 0
264−}
265−
266−// matchesGlob handles glob patterns using the doublestar library
267−func matchesGlob(pattern, path string) bool {
268− // Use doublestar for all glob matching - it handles ** and other complex patterns
269− matched, err := doublestar.Match(pattern, path)
270− if err != nil {
271− slog.Error("Error matching pattern", "pattern", pattern, "path", path, "error", err)
272− return false
273− }
274− return matched
275−}
276−
277−// matchesPattern checks if a path matches the glob pattern
278−func (w *Client) matchesPattern(path string, pattern protocol.GlobPattern) bool {
279− patternInfo, err := pattern.AsPattern()
280− if err != nil {
281− slog.Error("Error parsing pattern", "pattern", pattern, "error", err)
282− return false
283− }
284−
285− basePath := patternInfo.GetBasePath()
286− patternText := patternInfo.GetPattern()
287−
288− path = filepath.ToSlash(path)
289−
290− // For simple patterns without base path
291− if basePath == "" {
292− // Check if the pattern matches the full path or just the file extension
293− fullPathMatch := matchesGlob(patternText, path)
294− baseNameMatch := matchesGlob(patternText, filepath.Base(path))
295−
296− return fullPathMatch || baseNameMatch
297− }
298−
299− if basePath == "" {
300− return false
301− }
302−
303− // Make path relative to basePath for matching
304− relPath, err := filepath.Rel(basePath, path)
305− if err != nil {
306− slog.Error("Error getting relative path", "path", path, "basePath", basePath, "error", err, "server", w.name)
307− return false
308− }
309− relPath = filepath.ToSlash(relPath)
310−
311− isMatch := matchesGlob(patternText, relPath)
312−
313− return isMatch
314−}
315−
316−// notifyFileEvent sends a didChangeWatchedFiles notification for a file event
317−func (w *Client) notifyFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) error {
318− cfg := config.Get()
319− if cfg.Options.DebugLSP {
320− slog.Debug("Notifying file event",
321− "uri", uri,
322− "changeType", changeType,
323− )
324− }
325−
326− params := protocol.DidChangeWatchedFilesParams{
327− Changes: []protocol.FileEvent{
328− {
329− URI: protocol.DocumentURI(uri),
330− Type: changeType,
331− },
332− },
333− }
334−
335− return w.client.DidChangeWatchedFiles(ctx, params)
336−}
337−
338−// shouldPreloadFiles determines if we should preload files for a specific language server
339−// Some servers work better with preloaded files, others don't need it
340−func shouldPreloadFiles(serverName string) bool {
341− // TypeScript/JavaScript servers typically need some files preloaded
342− // to properly resolve imports and provide intellisense
343− switch serverName {
344− case "typescript", "typescript-language-server", "tsserver", "vtsls":
345− return true
346− case "java", "jdtls":
347− // Java servers often need to see source files to build the project model
348− return true
349− default:
350− // For most servers, we'll use lazy loading by default
351− return false
352− }
353−}
354−
355−// Common patterns for directories and files to exclude
356−// TODO: make configurable
357−var (
358− excludedFileExtensions = map[string]bool{
359− ".swp": true,
360− ".swo": true,
361− ".tmp": true,
362− ".temp": true,
363− ".bak": true,
364− ".log": true,
365− ".o": true, // Object files
366− ".so": true, // Shared libraries
367− ".dylib": true, // macOS shared libraries
368− ".dll": true, // Windows shared libraries
369− ".a": true, // Static libraries
370− ".exe": true, // Windows executables
371− ".lock": true, // Lock files
372− }
373−
374− // Large binary files that shouldn't be opened
375− largeBinaryExtensions = map[string]bool{
376− ".png": true,
377− ".jpg": true,
378− ".jpeg": true,
379− ".gif": true,
380− ".bmp": true,
381− ".ico": true,
382− ".zip": true,
383− ".tar": true,
384− ".gz": true,
385− ".rar": true,
386− ".7z": true,
387− ".pdf": true,
388− ".mp3": true,
389− ".mp4": true,
390− ".mov": true,
391− ".wav": true,
392− ".wasm": true,
393− }
394−
395− // Maximum file size to open (5MB)
396− maxFileSize int64 = 5 * 1024 * 1024
397−)
398−
399−// shouldExcludeFile returns true if the file should be excluded from opening
400−func shouldExcludeFile(filePath string) bool {
401− fileName := filepath.Base(filePath)
402− cfg := config.Get()
403−
404− // Skip dot files
405− if strings.HasPrefix(fileName, ".") {
406− return true
407− }
408−
409− // Check file extension
410− ext := strings.ToLower(filepath.Ext(filePath))
411− if excludedFileExtensions[ext] || largeBinaryExtensions[ext] {
412− return true
413− }
414−
415− info, err := os.Stat(filePath)
416− if err != nil {
417− // If we can't stat the file, skip it
418− return true
419− }
420−
421− // Skip large files
422− if info.Size() > maxFileSize {
423− if cfg.Options.DebugLSP {
424− slog.Debug("Skipping large file",
425− "path", filePath,
426− "size", info.Size(),
427− "maxSize", maxFileSize,
428− "debug", cfg.Options.Debug,
429− "sizeMB", float64(info.Size())/(1024*1024),
430− "maxSizeMB", float64(maxFileSize)/(1024*1024),
431− )
432− }
433− return true
434− }
435−
436− return false
437−}
438−
439−// openMatchingFile opens a file if it matches any of the registered patterns
440−func (w *Client) openMatchingFile(ctx context.Context, path string) {
441− cfg := config.Get()
442− // Skip directories
443− info, err := os.Stat(path)
444− if err != nil || info.IsDir() {
445− return
446− }
447−
448− // Skip excluded files
449− if shouldExcludeFile(path) {
450− return
451− }
452−
453− // Check if this path should be watched according to server registrations
454− if watched, _ := w.isPathWatched(path); !watched {
455− return
456− }
457−
458− serverName := w.name
459−
460− // Get server name for specialized handling
461− // Check if the file is a high-priority file that should be opened immediately
462− // This helps with project initialization for certain language servers
463− if isHighPriorityFile(path, serverName) {
464− if cfg.Options.DebugLSP {
465− slog.Debug("Opening high-priority file", "path", path, "serverName", serverName)
466− }
467− if err := w.client.OpenFile(ctx, path); err != nil && cfg.Options.DebugLSP {
468− slog.Error("Error opening high-priority file", "path", path, "error", err)
469− }
470− return
471− }
472−
473− // For non-high-priority files, we'll use different strategies based on server type
474− if !shouldPreloadFiles(serverName) {
475− return
476− }
477− // For servers that benefit from preloading, open files but with limits
478−
479− // Check file size - for preloading we're more conservative
480− if info.Size() > (1 * 1024 * 1024) { // 1MB limit for preloaded files
481− if cfg.Options.DebugLSP {
482− slog.Debug("Skipping large file for preloading", "path", path, "size", info.Size())
483− }
484− return
485− }
486−
487− // File type is already validated by HandlesFile() and isPathWatched() checks earlier,
488− // so we know this client handles this file type. Just open it.
489− if err := w.client.OpenFile(ctx, path); err != nil && cfg.Options.DebugLSP {
490− slog.Error("Error opening file", "path", path, "error", err)
491− }
492−}
493−
494−// isHighPriorityFile determines if a file should be opened immediately
495−// regardless of the preloading strategy
496−func isHighPriorityFile(path string, serverName string) bool {
497− fileName := filepath.Base(path)
498− ext := filepath.Ext(path)
499−
500− switch serverName {
501− case "typescript", "typescript-language-server", "tsserver", "vtsls":
502− // For TypeScript, we want to open configuration files immediately
503− return fileName == "tsconfig.json" ||
504− fileName == "package.json" ||
505− fileName == "jsconfig.json" ||
506− // Also open main entry points
507− fileName == "index.ts" ||
508− fileName == "index.js" ||
509− fileName == "main.ts" ||
510− fileName == "main.js"
511− case "gopls":
512− // For Go, we want to open go.mod files immediately
513− return fileName == "go.mod" ||
514− fileName == "go.sum" ||
515− // Also open main.go files
516− fileName == "main.go"
517− case "rust-analyzer":
518− // For Rust, we want to open Cargo.toml files immediately
519− return fileName == "Cargo.toml" ||
520− fileName == "Cargo.lock" ||
521− // Also open lib.rs and main.rs
522− fileName == "lib.rs" ||
523− fileName == "main.rs"
524− case "python", "pyright", "pylsp":
525− // For Python, open key project files
526− return fileName == "pyproject.toml" ||
527− fileName == "setup.py" ||
528− fileName == "requirements.txt" ||
529− fileName == "__init__.py" ||
530− fileName == "__main__.py"
531− case "clangd":
532− // For C/C++, open key project files
533− return fileName == "CMakeLists.txt" ||
534− fileName == "Makefile" ||
535− fileName == "compile_commands.json"
536− case "java", "jdtls":
537− // For Java, open key project files
538− return fileName == "pom.xml" ||
539− fileName == "build.gradle" ||
540− ext == ".java" // Java servers often need to see source files
541− }
542−
543− // For unknown servers, prioritize common configuration files
544− return fileName == "package.json" ||
545− fileName == "Makefile" ||
546− fileName == "CMakeLists.txt" ||
547− fileName == ".editorconfig"
548−}