diff --git a/internal/ls/diagnostics.go b/internal/ls/diagnostics.go index 87ace009f9d..98facc493db 100644 --- a/internal/ls/diagnostics.go +++ b/internal/ls/diagnostics.go @@ -34,6 +34,23 @@ func (l *LanguageService) ProvideDiagnostics(ctx context.Context, uri lsproto.Do }, nil } +// ProvidePushDiagnostics computes diagnostics for a file in the format used by +// textDocument/publishDiagnostics. +func (l *LanguageService) ProvidePushDiagnostics(ctx context.Context, uri lsproto.DocumentUri) []*lsproto.Diagnostic { + program, file := l.tryGetProgramAndFile(uri.FileName()) + if file == nil { + return nil + } + + diagnostics := getAllDiagnostics(ctx, program, file) + + lspDiagnostics := make([]*lsproto.Diagnostic, 0, len(diagnostics)) + for _, diag := range diagnostics { + lspDiagnostics = append(lspDiagnostics, lsconv.DiagnosticToLSPPush(ctx, l.converters, diag)) + } + return lspDiagnostics +} + func (l *LanguageService) toLSPDiagnostics(ctx context.Context, diagnostics ...[]*ast.Diagnostic) []*lsproto.Diagnostic { size := 0 for _, diagSlice := range diagnostics { diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 4e5e00989db..0e07d9efda5 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -1235,19 +1235,28 @@ func (s *Server) handleInitialized(ctx context.Context, params *lsproto.Initiali s.telemetryEnabled = enableTelemetry + // textDocument/publishDiagnostics is a baseline notification; its capability + // only advertises extensions like versionSupport, so it is not checked here. + var clientSupportsPullDiagnostics bool + if capabilities := s.initializeParams.Capabilities; capabilities != nil && capabilities.TextDocument != nil { + clientSupportsPullDiagnostics = capabilities.TextDocument.Diagnostic != nil + } + pushFileDiagnostics := !disablePushDiagnostics && !clientSupportsPullDiagnostics + s.session = project.NewSession(&project.SessionInit{ BackgroundCtx: lsproto.WithClientCapabilities(s.backgroundCtx, &s.clientCapabilities), Options: &project.SessionOptions{ - CurrentDirectory: cwd, - DefaultLibraryPath: s.defaultLibraryPath, - TypingsLocation: s.typingsLocation, - PositionEncoding: s.positionEncoding, - WatchEnabled: s.watchEnabled, - LoggingEnabled: true, - TelemetryEnabled: enableTelemetry, - DebounceDelay: 500 * time.Millisecond, - PushDiagnosticsEnabled: !disablePushDiagnostics, - Locale: s.locale, + CurrentDirectory: cwd, + DefaultLibraryPath: s.defaultLibraryPath, + TypingsLocation: s.typingsLocation, + PositionEncoding: s.positionEncoding, + WatchEnabled: s.watchEnabled, + LoggingEnabled: true, + TelemetryEnabled: enableTelemetry, + DebounceDelay: 500 * time.Millisecond, + PushDiagnosticsEnabled: !disablePushDiagnostics, + PushFileDiagnosticsEnabled: pushFileDiagnostics, + Locale: s.locale, }, FS: s.fs, Logger: s.logger, diff --git a/internal/project/project_test.go b/internal/project/project_test.go index b819f860d7c..93f34ba4821 100644 --- a/internal/project/project_test.go +++ b/internal/project/project_test.go @@ -659,3 +659,124 @@ func TestProgressNotifications(t *testing.T) { assert.Equal(t, starts, finishes, "ProgressStart and ProgressFinish calls for Project_0 should be balanced") }) } + +func TestPushFileDiagnostics(t *testing.T) { + t.Parallel() + if !bundled.Embedded { + t.Skip("bundled files are not embedded") + } + + files := map[string]any{ + "/src/tsconfig.json": `{}`, + "/src/index.ts": `const x: number = "";`, + } + options := &project.SessionOptions{ + CurrentDirectory: "/", + DefaultLibraryPath: bundled.LibPath(), + PositionEncoding: lsproto.PositionEncodingKindUTF8, + WatchEnabled: true, + LoggingEnabled: true, + PushDiagnosticsEnabled: true, + PushFileDiagnosticsEnabled: true, + } + setup := func() (*project.Session, *projecttestutil.SessionUtils) { + init, utils := projecttestutil.GetSessionInitOptions(files, options, &projecttestutil.TypingsInstallerOptions{}) + versionSupport := true + caps := (&lsproto.ClientCapabilities{ + TextDocument: &lsproto.TextDocumentClientCapabilities{ + PublishDiagnostics: &lsproto.PublishDiagnosticsClientCapabilities{VersionSupport: &versionSupport}, + }, + }).Resolve() + init.BackgroundCtx = lsproto.WithClientCapabilities(context.Background(), &caps) + return project.NewSession(init), utils + } + uri := lsproto.DocumentUri("file:///src/index.ts") + + t.Run("publishes file diagnostics on open", func(t *testing.T) { + t.Parallel() + session, utils := setup() + session.DidOpenFile(context.Background(), uri, 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript) + session.WaitForBackgroundTasks() + + calls := filterDiagnosticsByURI(utils.Client().PublishDiagnosticsCalls(), uri, 0) + assert.Assert(t, len(calls) > 0, "expected PublishDiagnostics call for index.ts") + last := calls[len(calls)-1] + assert.Equal(t, len(last.Params.Diagnostics), 1) + assert.Equal(t, *last.Params.Version, int32(1)) + }) + + t.Run("publishes updated file diagnostics on change", func(t *testing.T) { + t.Parallel() + session, utils := setup() + session.DidOpenFile(context.Background(), uri, 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript) + session.WaitForBackgroundTasks() + + session.DidChangeFile(context.Background(), uri, 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{ + {WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: `const x: number = 1;`}}, + }) + session.WaitForBackgroundTasks() + + calls := filterDiagnosticsByURI(utils.Client().PublishDiagnosticsCalls(), uri, 0) + assert.Assert(t, len(calls) > 1, "expected PublishDiagnostics call after change") + last := calls[len(calls)-1] + assert.Equal(t, len(last.Params.Diagnostics), 0) + assert.Equal(t, *last.Params.Version, int32(2)) + }) + + t.Run("clears file diagnostics on close", func(t *testing.T) { + t.Parallel() + session, utils := setup() + session.DidOpenFile(context.Background(), uri, 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript) + session.WaitForBackgroundTasks() + + session.DidCloseFile(context.Background(), uri) + session.WaitForBackgroundTasks() + + calls := filterDiagnosticsByURI(utils.Client().PublishDiagnosticsCalls(), uri, 0) + assert.Assert(t, len(calls) > 1, "expected PublishDiagnostics call after close") + last := calls[len(calls)-1] + assert.Equal(t, len(last.Params.Diagnostics), 0) + assert.Assert(t, last.Params.Version == nil) + }) + + t.Run("omits version when client does not support it", func(t *testing.T) { + t.Parallel() + session, utils := projecttestutil.SetupWithOptions(files, options) + session.DidOpenFile(context.Background(), uri, 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript) + session.WaitForBackgroundTasks() + + calls := filterDiagnosticsByURI(utils.Client().PublishDiagnosticsCalls(), uri, 0) + assert.Assert(t, len(calls) > 0, "expected PublishDiagnostics call for index.ts") + last := calls[len(calls)-1] + assert.Equal(t, len(last.Params.Diagnostics), 1) + assert.Assert(t, last.Params.Version == nil) + }) + + t.Run("does not publish empty diagnostics for files that never had any", func(t *testing.T) { + t.Parallel() + cleanFiles := map[string]any{ + "/src/tsconfig.json": `{}`, + "/src/index.ts": `const x: number = 1;`, + } + session, utils := projecttestutil.SetupWithOptions(cleanFiles, options) + session.DidOpenFile(context.Background(), uri, 1, cleanFiles["/src/index.ts"].(string), lsproto.LanguageKindTypeScript) + session.WaitForBackgroundTasks() + session.DidCloseFile(context.Background(), uri) + session.WaitForBackgroundTasks() + + calls := filterDiagnosticsByURI(utils.Client().PublishDiagnosticsCalls(), uri, 0) + assert.Equal(t, len(calls), 0) + }) + + t.Run("does not publish file diagnostics when disabled", func(t *testing.T) { + t.Parallel() + session, utils := projecttestutil.Setup(files) + session.DidOpenFile(context.Background(), uri, 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript) + _, err := session.GetLanguageService(context.Background(), uri) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + + calls := filterDiagnosticsByURI(utils.Client().PublishDiagnosticsCalls(), uri, 0) + assert.Equal(t, len(calls), 0) + }) +} diff --git a/internal/project/session.go b/internal/project/session.go index cd04d9817ab..70d08d0d696 100644 --- a/internal/project/session.go +++ b/internal/project/session.go @@ -36,6 +36,8 @@ const ( UpdateReasonUnknown UpdateReason = iota UpdateReasonDidOpenFile UpdateReasonDidCloseFile + UpdateReasonDidChangeFile + UpdateReasonDiagnosticsRefresh UpdateReasonDidChangeCompilerOptionsForInferredProjects UpdateReasonRequestedLanguageServicePendingChanges UpdateReasonRequestedLanguageServiceProjectNotLoaded @@ -61,9 +63,12 @@ type SessionOptions struct { LoggingEnabled bool TelemetryEnabled bool PushDiagnosticsEnabled bool - DebounceDelay time.Duration - Locale locale.Locale - CheckerPoolOptions CheckerPoolOptions + // PushFileDiagnosticsEnabled pushes per-file diagnostics for open files, + // for clients that do not support pull diagnostics. + PushFileDiagnosticsEnabled bool + DebounceDelay time.Duration + Locale locale.Locale + CheckerPoolOptions CheckerPoolOptions } type SessionInit struct { @@ -175,6 +180,14 @@ type Session struct { // task should be enqueued. It is reset when the task runs, coalescing multiple // requests into a single background task. globalDiagPublishPending atomic.Bool + + // pushedDiagnosticsFiles tracks open files whose last published diagnostics + // were non-empty, so redundant empty publishes can be skipped. + pushedDiagnosticsFiles collections.SyncSet[tspath.Path] +} + +func (s *Session) pushFileDiagnosticsEnabled() bool { + return s.options.PushDiagnosticsEnabled && s.options.PushFileDiagnosticsEnabled } func NewSession(init *SessionInit) *Session { @@ -334,13 +347,16 @@ func (s *Session) DidChangeFile(ctx context.Context, uri lsproto.DocumentUri, ve s.cancelWarmAutoImportCache() s.scheduleIdleCacheClean() s.pendingFileChangesMu.Lock() - defer s.pendingFileChangesMu.Unlock() s.pendingFileChanges = append(s.pendingFileChanges, FileChange{ Kind: FileChangeKindChange, URI: uri, Version: version, Changes: changes, }) + s.pendingFileChangesMu.Unlock() + if s.pushFileDiagnosticsEnabled() { + s.ScheduleSnapshotUpdate(UpdateReasonDidChangeFile) + } } func (s *Session) DidSaveFile(ctx context.Context, uri lsproto.DocumentUri) { @@ -392,6 +408,12 @@ func (s *Session) DidChangeCompilerOptionsForInferredProjects(ctx context.Contex } func (s *Session) ScheduleDiagnosticsRefresh() { + if s.pushFileDiagnosticsEnabled() { + // Push-only clients can't re-pull in response to workspace/diagnostic/refresh. + s.ScheduleSnapshotUpdate(UpdateReasonDiagnosticsRefresh) + return + } + s.diagnosticsRefreshMu.Lock() defer s.diagnosticsRefreshMu.Unlock() @@ -503,12 +525,23 @@ func (s *Session) ScheduleSnapshotUpdate(reason UpdateReason) { return } - s.UpdateSnapshot(ctx, overlays, SnapshotChange{ + change := SnapshotChange{ reason: reason, fileChanges: fileChanges, ataChanges: ataChanges, newConfig: newConfig, - }) + } + if s.pushFileDiagnosticsEnabled() { + // Request open documents so dirty programs are rebuilt eagerly; + // push-only clients send no requests that would otherwise do it. + documents := make([]lsproto.DocumentUri, 0, len(overlays)) + for _, overlay := range overlays { + documents = append(documents, lsconv.FileNameToDocumentURI(overlay.FileName())) + } + slices.Sort(documents) + change.ResourceRequest = ResourceRequest{Documents: documents} + } + s.UpdateSnapshot(ctx, overlays, change) }) } @@ -1237,6 +1270,7 @@ func (s *Session) updateSnapshot(ctx context.Context, overlays map[tspath.Path]* } } s.publishProgramDiagnostics(oldSnapshot, newSnapshot) + s.publishOpenFileDiagnostics(oldSnapshot, newSnapshot) s.sendProjectInfoTelemetryForNewProjects(oldSnapshot, newSnapshot) s.warmAutoImportCache(ctx, change, oldSnapshot, newSnapshot) }) @@ -1690,6 +1724,66 @@ func (s *Session) publishProjectDiagnostics(ctx context.Context, configFilePath } } +// publishOpenFileDiagnostics pushes diagnostics for open files after a +// snapshot update, for clients that do not support pull diagnostics. +func (s *Session) publishOpenFileDiagnostics(oldSnapshot *Snapshot, newSnapshot *Snapshot) { + if !s.pushFileDiagnosticsEnabled() { + return + } + + ctx := s.backgroundCtx + for path, overlay := range oldSnapshot.fs.overlays { + if _, stillOpen := newSnapshot.fs.overlays[path]; !stillOpen && s.pushedDiagnosticsFiles.Has(path) { + s.pushedDiagnosticsFiles.Delete(path) + s.publishFileDiagnostics(ctx, lsconv.FileNameToDocumentURI(overlay.FileName()), nil, nil) + } + } + + for path, overlay := range newSnapshot.fs.overlays { + uri := lsconv.FileNameToDocumentURI(overlay.FileName()) + project := newSnapshot.GetDefaultProject(uri) + if project == nil { + continue + } + program := project.GetProgram() + if program == nil { + continue + } + _, wasOpen := oldSnapshot.fs.overlays[path] + if wasOpen && project.ProgramLastUpdate != newSnapshot.ID() { + continue + } + languageService := ls.NewLanguageService(project.configFilePath, program, newSnapshot, overlay.FileName()) + diagnostics := languageService.ProvidePushDiagnostics(ctx, uri) + if len(diagnostics) == 0 { + if !s.pushedDiagnosticsFiles.Has(path) { + continue + } + s.pushedDiagnosticsFiles.Delete(path) + } else { + s.pushedDiagnosticsFiles.Add(path) + } + version := overlay.Version() + s.publishFileDiagnostics(ctx, uri, &version, diagnostics) + } +} + +func (s *Session) publishFileDiagnostics(ctx context.Context, uri lsproto.DocumentUri, version *int32, diagnostics []*lsproto.Diagnostic) { + if diagnostics == nil { + diagnostics = []*lsproto.Diagnostic{} + } + if !lsproto.GetClientCapabilities(ctx).TextDocument.PublishDiagnostics.VersionSupport { + version = nil + } + if err := s.client.PublishDiagnostics(ctx, &lsproto.PublishDiagnosticsParams{ + Uri: uri, + Version: version, + Diagnostics: diagnostics, + }); err != nil && s.options.LoggingEnabled { + s.logger.Logf("Error publishing diagnostics: %v", err) + } +} + // EnqueuePublishGlobalDiagnostics schedules a background check for new accumulated // global diagnostics from checker pools, re-publishing tsconfig diagnostics if changed. // Multiple calls are coalesced into a single background task. diff --git a/internal/project/snapshot.go b/internal/project/snapshot.go index fde56333707..48d07ea5df0 100644 --- a/internal/project/snapshot.go +++ b/internal/project/snapshot.go @@ -266,6 +266,10 @@ func (s *Snapshot) Clone(ctx context.Context, change SnapshotChange, overlays ma logger.Logf("Reason: DidOpenFile - %s", change.fileChanges.Opened) case UpdateReasonDidCloseFile: logger.Logf("Reason: DidCloseFile - %v", change.fileChanges.Closed) + case UpdateReasonDidChangeFile: + logger.Logf("Reason: DidChangeFile - %v", change.fileChanges.Changed) + case UpdateReasonDiagnosticsRefresh: + logger.Logf("Reason: DiagnosticsRefresh") case UpdateReasonDidChangeCompilerOptionsForInferredProjects: logger.Logf("Reason: DidChangeCompilerOptionsForInferredProjects") case UpdateReasonRequestedLanguageServicePendingChanges: