on main help sign in
feat(lsp): remove internal watcher (#1062)

* feat(lsp): remove internal watcher It was only ever useful if the user edits files through their editor, but we don't really need it assuming we only care about edits done by Crush itself. Basically, we lose that functionality, but save a bunch of file descriptors and have improved perf. Damn good deal if you ask me. Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> * fix: cleanup Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> * fix: remove rlimit Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> * fix: more cleanup Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> --------- Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

commit016b3b6dddf8dbafc3fd186086533edc2f9a42c5 authorCarlos Alexandro Becker <caarlos0@users.noreply.github.com> dateThu 18 Sep 2025, 10:58 (1 year ago) parentf02ce4b
18 files changed+169−1497
go.mod−1 go.sum−3 internal/app/app.go−38 internal/app/lsp.go−58 internal/csync/maps.go+4−4 internal/csync/versionedmap.go+35 internal/csync/versionedmap_test.go+89 internal/llm/tools/diagnostics.go+9−74 internal/llm/tools/edit.go+2−1 internal/llm/tools/multiedit.go+3−1 internal/llm/tools/view.go+1−1 internal/llm/tools/write.go+2−1 internal/lsp/client.go+24−2 internal/lsp/watcher/global_watcher.go−394 internal/lsp/watcher/global_watcher_test.go−302 internal/lsp/watcher/rlimit_stub.go−12 internal/lsp/watcher/rlimit_unix.go−57 internal/lsp/watcher/watcher.go−548
go.mod−1
@@ -114,7 +114,6 @@ require (
114114 github.com/ncruces/julianday v1.0.0 // indirect
115115 github.com/pierrec/lz4/v4 v4.1.22 // indirect
116116 github.com/pmezard/go-difflib v1.0.0 // indirect
117− github.com/raphamorim/notify v0.9.4
118117 github.com/rivo/uniseg v0.4.7
119118 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
120119 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
237237github.com/pressly/goose/v3 v3.25.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
238238github.com/qjebbs/go-jsons v0.0.0-20221222033332-a534c5fc1c4c h1:kmzxiX+OB0knCo1V0dkEkdPelzCdAzCURCfmFArn2/A=
239239github.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=
242240github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
243241github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
244242github.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=
370368golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
371369golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
372370golang.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=
374371golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
375372golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
376373golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
internal/app/app.go−38
@@ -7,14 +7,11 @@ import (
77 "fmt"
88 "log/slog"
99 "maps"
10− "os/exec"
11− "strings"
1210 "sync"
1311 "time"
1412
1513 tea "github.com/charmbracelet/bubbletea/v2"
1614 "github.com/charmbracelet/crush/internal/config"
17− "github.com/charmbracelet/crush/internal/csync"
1815 "github.com/charmbracelet/crush/internal/db"
1916 "github.com/charmbracelet/crush/internal/format"
2017 "github.com/charmbracelet/crush/internal/history"
@@ -23,7 +20,6 @@ import (
2320 "github.com/charmbracelet/crush/internal/pubsub"
2421
2522 "github.com/charmbracelet/crush/internal/lsp"
26− "github.com/charmbracelet/crush/internal/lsp/watcher"
2723 "github.com/charmbracelet/crush/internal/message"
2824 "github.com/charmbracelet/crush/internal/permission"
2925 "github.com/charmbracelet/crush/internal/session"
@@ -41,9 +37,6 @@ type App struct {
4137
4238 clientsMutex sync.RWMutex
4339
44− watcherCancelFuncs *csync.Slice[context.CancelFunc]
45− lspWatcherWG sync.WaitGroup
46−
4740 config *config.Config
4841
4942 serviceEventsWG *sync.WaitGroup
@@ -56,16 +49,6 @@ type App struct {
5649 cleanupFuncs []func() error
5750}
5851
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−
6952// New initializes a new applcation instance.
7053func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
7154 q := db.New(conn)
@@ -89,8 +72,6 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
8972
9073 config: cfg,
9174
92− watcherCancelFuncs: csync.NewSlice[context.CancelFunc](),
93−
9475 events: make(chan tea.Msg, 100),
9576 serviceEventsWG: &sync.WaitGroup{},
9677 tuiWG: &sync.WaitGroup{},
@@ -98,15 +79,6 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
9879
9980 app.setupEvents()
10081
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−
11082 // Initialize LSP clients in the background.
11183 app.initLSPClients(ctx)
11284
@@ -352,13 +324,6 @@ func (app *App) Shutdown() {
352324 app.CoderAgent.CancelAll()
353325 }
354326
355− for cancel := range app.watcherCancelFuncs.Seq() {
356− cancel()
357− }
358−
359− // Wait for all LSP watchers to finish.
360− app.lspWatcherWG.Wait()
361−
362327 // Get all LSP clients.
363328 app.clientsMutex.RLock()
364329 clients := make(map[string]*lsp.Client, len(app.LSPClients))
@@ -374,9 +339,6 @@ func (app *App) Shutdown() {
374339 cancel()
375340 }
376341
377− // Shutdown the global watcher
378− watcher.Shutdown()
379−
380342 // Call call cleanup functions.
381343 for _, cleanup := range app.cleanupFuncs {
382344 if cleanup != nil {
internal/app/lsp.go−58
@@ -6,9 +6,7 @@ import (
66 "time"
77
88 "github.com/charmbracelet/crush/internal/config"
9− "github.com/charmbracelet/crush/internal/log"
109 "github.com/charmbracelet/crush/internal/lsp"
11− "github.com/charmbracelet/crush/internal/lsp/watcher"
1210)
1311
1412// initLSPClients initializes LSP clients.
@@ -77,64 +75,8 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, config
7775
7876 slog.Info("LSP client initialized", "name", name)
7977
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−
9078 // Add to map with mutex protection before starting goroutine
9179 app.clientsMutex.Lock()
9280 app.LSPClients[name] = lspClient
9381 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)
14082}
internal/csync/maps.go+4−4
@@ -70,10 +70,10 @@ func (m *Map[K, V]) GetOrSet(key K, fn func() V) V {
7070
7171// Take gets an item and then deletes it.
7272func (m *Map[K, V]) Take(key K) (V, bool) {
73− m.mu.Lock()
74− defer m.mu.Unlock()
75− v, ok := m.inner[key]
76− delete(m.inner, key)
73+ v, ok := m.Get(key)
74+ if ok {
75+ m.Del(key)
76+ }
7777 return v, ok
7878}
7979
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 (
1616type DiagnosticsParams struct {
1717 FilePath string `json:"file_path"`
1818}
19+
1920type diagnosticsTool struct {
2021 lspClients map[string]*lsp.Client
2122}
@@ -76,91 +77,26 @@ func (b *diagnosticsTool) Run(ctx context.Context, call ToolCall) (ToolResponse,
7677 }
7778
7879 lsps := b.lspClients
79−
8080 if len(lsps) == 0 {
8181 return NewTextErrorResponse("no LSP clients available"), nil
8282 }
83−
84− if params.FilePath != "" {
85− notifyLspOpenFile(ctx, params.FilePath, lsps)
86− waitForLspDiagnostics(ctx, params.FilePath, lsps)
87− }
88−
83+ notifyLSPs(ctx, lsps, params.FilePath)
8984 output := getDiagnostics(params.FilePath, lsps)
90−
9185 return NewTextResponse(output), nil
9286}
9387
94−func notifyLspOpenFile(ctx context.Context, filePath string, lsps map[string]*lsp.Client) {
95− for _, client := range lsps {
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 {
88+func notifyLSPs(ctx context.Context, lsps map[string]*lsp.Client, filepath string) {
89+ if filepath == "" {
10590 return
10691 }
107−
108− diagChan := make(chan struct{}, 1)
109−
11092 for _, client := range lsps {
111− originalDiags := client.GetDiagnostics()
112−
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
93+ if !client.HandlesFile(filepath) {
94+ continue
16195 }
96+ _ = client.OpenFileOnDemand(ctx, filepath)
97+ _ = client.NotifyChange(ctx, filepath)
98+ client.WaitForDiagnostics(ctx, 5*time.Second)
16299 }
163− return false
164100}
165101
166102func getDiagnostics(filePath string, lsps map[string]*lsp.Client) string {
@@ -198,7 +134,6 @@ func getDiagnostics(filePath string, lsps map[string]*lsp.Client) string {
198134 fileWarnings := countSeverity(fileDiagnostics, "Warn")
199135 projectErrors := countSeverity(projectDiagnostics, "Error")
200136 projectWarnings := countSeverity(projectDiagnostics, "Warn")
201−
202137 output.WriteString("\n<diagnostic_summary>\n")
203138 fmt.Fprintf(&output, "Current file: %d errors, %d warnings\n", fileErrors, fileWarnings)
204139 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)
184184 return response, nil
185185 }
186186
187− waitForLspDiagnostics(ctx, params.FilePath, e.lspClients)
187+ notifyLSPs(ctx, e.lspClients, params.FilePath)
188+
188189 text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
189190 text += getDiagnostics(params.FilePath, e.lspClients)
190191 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
188188 return response, nil
189189 }
190190
191+ // Notify LSP clients about the change
192+ notifyLSPs(ctx, m.lspClients, params.FilePath)
193+
191194 // Wait for LSP diagnostics and add them to the response
192− waitForLspDiagnostics(ctx, params.FilePath, m.lspClients)
193195 text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
194196 text += getDiagnostics(params.FilePath, m.lspClients)
195197 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)
233233 return ToolResponse{}, fmt.Errorf("error reading file: %w", err)
234234 }
235235
236− notifyLspOpenFile(ctx, filePath, v.lspClients)
236+ notifyLSPs(ctx, v.lspClients, filePath)
237237 output := "<file>\n"
238238 // Format the output with line numbers
239239 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
221221
222222 recordFileWrite(filePath)
223223 recordFileRead(filePath)
224− waitForLspDiagnostics(ctx, filePath, w.lspClients)
224+
225+ notifyLSPs(ctx, w.lspClients, params.FilePath)
225226
226227 result := fmt.Sprintf("File successfully written: %s", filePath)
227228 result = fmt.Sprintf("<result>\n%s\n</result>", result)
internal/lsp/client.go+24−2
@@ -34,7 +34,7 @@ type Client struct {
3434 onDiagnosticsChanged func(name string, count int)
3535
3636 // Diagnostic cache
37− diagnostics *csync.Map[protocol.DocumentURI, []protocol.Diagnostic]
37+ diagnostics *csync.VersionedMap[protocol.DocumentURI, []protocol.Diagnostic]
3838
3939 // Files are currently opened by the LSP
4040 openFiles *csync.Map[string, *OpenFileInfo]
@@ -83,7 +83,7 @@ func New(ctx context.Context, name string, config config.LSPConfig) (*Client, er
8383 client: powernapClient,
8484 name: name,
8585 fileTypes: config.FileTypes,
86− diagnostics: csync.NewMap[protocol.DocumentURI, []protocol.Diagnostic](),
86+ diagnostics: csync.NewVersionedMap[protocol.DocumentURI, []protocol.Diagnostic](),
8787 openFiles: csync.NewMap[string, *OpenFileInfo](),
8888 config: config,
8989 }
@@ -314,6 +314,8 @@ func (c *Client) NotifyChange(ctx context.Context, filepath string) error {
314314}
315315
316316// CloseFile closes a file in the LSP server.
317+//
318+// NOTE: this is only ever called on LSP shutdown.
317319func (c *Client) CloseFile(ctx context.Context, filepath string) error {
318320 cfg := config.Get()
319321 uri := string(protocol.URIFromPath(filepath))
@@ -454,6 +456,26 @@ func (c *Client) openKeyConfigFiles(ctx context.Context) {
454456 }
455457}
456458
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+
457479// HasRootMarkers checks if any of the specified root marker patterns exist in the given directory.
458480// Uses glob patterns to match files, allowing for more flexible matching.
459481func 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−}