diff --git a/internal/lsp/client.go b/internal/lsp/client.go index fc07059d..489f6f93 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -41,6 +41,11 @@ type Client struct { diagnostics map[protocol.DocumentUri][]protocol.Diagnostic diagnosticsMu sync.RWMutex + // Per-URI waiters fired when textDocument/publishDiagnostics arrives. + // Used by WaitForDiagnostics to replace the old 3s sleep. + diagnosticWaiters map[protocol.DocumentUri][]chan struct{} + diagnosticWaitersMu sync.Mutex + // Files are currently opened by the LSP openFiles map[string]*OpenFileInfo openFilesMu sync.RWMutex @@ -75,6 +80,7 @@ func NewClient(command string, args ...string) (*Client, error) { notificationHandlers: make(map[string]NotificationHandler), serverRequestHandlers: make(map[string]ServerRequestHandler), diagnostics: make(map[protocol.DocumentUri][]protocol.Diagnostic), + diagnosticWaiters: make(map[protocol.DocumentUri][]chan struct{}), openFiles: make(map[string]*OpenFileInfo), } @@ -281,6 +287,54 @@ func (c *Client) WaitForServerReady(ctx context.Context) error { return nil } +// WaitForDiagnostics blocks until the LSP publishes diagnostics for uri or +// timeout expires, whichever comes first. After the first publish, sleeps +// settle to coalesce follow-up updates (e.g. project-wide rescans). The +// caller reads from the diagnostic cache after this returns. +func (c *Client) WaitForDiagnostics(ctx context.Context, uri protocol.DocumentUri, timeout, settle time.Duration) { + ch := make(chan struct{}, 1) + + c.diagnosticWaitersMu.Lock() + c.diagnosticWaiters[uri] = append(c.diagnosticWaiters[uri], ch) + c.diagnosticWaitersMu.Unlock() + + defer func() { + c.diagnosticWaitersMu.Lock() + waiters := c.diagnosticWaiters[uri] + for i, w := range waiters { + if w == ch { + c.diagnosticWaiters[uri] = append(waiters[:i], waiters[i+1:]...) + break + } + } + if len(c.diagnosticWaiters[uri]) == 0 { + delete(c.diagnosticWaiters, uri) + } + c.diagnosticWaitersMu.Unlock() + }() + + // If diagnostics already cached (file was opened earlier), fire immediately. + c.diagnosticsMu.RLock() + _, hasExisting := c.diagnostics[uri] + c.diagnosticsMu.RUnlock() + if hasExisting { + return + } + + select { + case <-ch: + // First publish arrived. Sleep settle to absorb redundant follow-ups + // (Civet republishes after a ~100ms project-wide propagation pass). + select { + case <-time.After(settle): + case <-ctx.Done(): + } + case <-time.After(timeout): + lspLogger.Debug("WaitForDiagnostics timed out for %s after %s", uri, timeout) + case <-ctx.Done(): + } +} + type OpenFileInfo struct { Version int32 URI protocol.DocumentUri diff --git a/internal/lsp/server-request-handlers.go b/internal/lsp/server-request-handlers.go index a79178de..55ce787c 100644 --- a/internal/lsp/server-request-handlers.go +++ b/internal/lsp/server-request-handlers.go @@ -124,5 +124,16 @@ func HandleDiagnostics(client *Client, params json.RawMessage) { client.diagnostics[diagParams.URI] = diagParams.Diagnostics client.diagnosticsMu.Unlock() + // Signal any WaitForDiagnostics callers blocked on this URI. + client.diagnosticWaitersMu.Lock() + waiters := client.diagnosticWaiters[diagParams.URI] + client.diagnosticWaitersMu.Unlock() + for _, w := range waiters { + select { + case w <- struct{}{}: + default: + } + } + lspLogger.Info("Received diagnostics for %s: %d items", diagParams.URI, len(diagParams.Diagnostics)) } diff --git a/internal/tools/diagnostics.go b/internal/tools/diagnostics.go index 5b96b406..2d903e6b 100644 --- a/internal/tools/diagnostics.go +++ b/internal/tools/diagnostics.go @@ -26,20 +26,23 @@ func GetDiagnosticsForFile(ctx context.Context, client *lsp.Client, filePath str return "", fmt.Errorf("could not open file: %v", err) } - // Wait for diagnostics - // TODO: wait for notification - time.Sleep(time.Second * 3) - // Convert the file path to URI format uri := protocol.DocumentUri("file://" + filePath) - // Request fresh diagnostics + // Wait for the LSP to publish diagnostics for this URI (push mode). + // 3s upper bound matches the previous hardcoded sleep; settle window + // (150ms) absorbs follow-up republishes some servers send after a + // project-wide rescan. + client.WaitForDiagnostics(ctx, uri, 3*time.Second, 150*time.Millisecond) + + // Also attempt pull-mode (textDocument/diagnostic) for servers that + // support it. Push-only servers (e.g. Civet) return -32601; that's + // fine since the cache is already populated from publishDiagnostics. diagParams := protocol.DocumentDiagnosticParams{ TextDocument: protocol.TextDocumentIdentifier{URI: uri}, } - _, err = client.Diagnostic(ctx, diagParams) - if err != nil { - toolsLogger.Error("Failed to get diagnostics: %v", err) + if _, err = client.Diagnostic(ctx, diagParams); err != nil { + toolsLogger.Debug("Pull-mode diagnostic unavailable (server likely push-only): %v", err) } // Get diagnostics from the cache