diff --git a/tsc/internal/ls/diagnostics.go b/tsc/internal/ls/diagnostics.go index 3638efdac813a..16bd631ad894e 100644 --- a/tsc/internal/ls/diagnostics.go +++ b/tsc/internal/ls/diagnostics.go @@ -50,7 +50,29 @@ 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 { + if l.UserPreferences().EnableValidation.IsFalse() { + return nil + } + + program, file := l.tryGetProgramAndFile(uri.FileName()) + if file == nil { + return nil + } + + return l.toLSPDiagnosticsWith(ctx, lsconv.DiagnosticToLSPPush, getAllDiagnostics(ctx, program, file)) +} + func (l *LanguageService) toLSPDiagnostics(ctx context.Context, diagnostics ...[]*ast.Diagnostic) []*lsproto.Diagnostic { + return l.toLSPDiagnosticsWith(ctx, lsconv.DiagnosticToLSPPull, diagnostics...) +} + +// toLSPDiagnosticsWith normalizes file diagnostics (style checks as warnings, +// synthesized content-mapper aggregation) and converts them with the given +// pull or push converter, so both client kinds receive equivalent diagnostics. +func (l *LanguageService) toLSPDiagnosticsWith(ctx context.Context, convert func(context.Context, *lsconv.Converters, *ast.Diagnostic, bool) *lsproto.Diagnostic, diagnostics ...[]*ast.Diagnostic) []*lsproto.Diagnostic { reportStyleChecksAsWarnings := l.UserPreferences().ReportStyleChecksAsWarnings.IsTrue() size := 0 for _, diagSlice := range diagnostics { @@ -68,12 +90,12 @@ func (l *LanguageService) toLSPDiagnostics(ctx context.Context, diagnostics ...[ synthesizedByFile.Set(diag.File(), append(synthesizedByFile.GetOrZero(diag.File()), diag)) continue } - lspDiagnostics = append(lspDiagnostics, lsconv.DiagnosticToLSPPull(ctx, l.converters, diag, reportStyleChecksAsWarnings)) + lspDiagnostics = append(lspDiagnostics, convert(ctx, l.converters, diag, reportStyleChecksAsWarnings)) } } for file, diags := range synthesizedByFile.Entries() { aggregate := aggregateSynthesizedDiagnostics(file, diags) - lspDiagnostics = append(lspDiagnostics, lsconv.DiagnosticToLSPPull(ctx, l.converters, aggregate, reportStyleChecksAsWarnings)) + lspDiagnostics = append(lspDiagnostics, convert(ctx, l.converters, aggregate, reportStyleChecksAsWarnings)) } return lspDiagnostics } diff --git a/tsc/internal/ls/lsconv/converters.go b/tsc/internal/ls/lsconv/converters.go index 4bdda4f1188f6..812e19275db9b 100644 --- a/tsc/internal/ls/lsconv/converters.go +++ b/tsc/internal/ls/lsconv/converters.go @@ -461,13 +461,14 @@ func DiagnosticToLSPPull(ctx context.Context, converters *Converters, diagnostic } // DiagnosticToLSPPush converts a diagnostic for push diagnostics (textDocument/publishDiagnostics) -func DiagnosticToLSPPush(ctx context.Context, converters *Converters, diagnostic *ast.Diagnostic) *lsproto.Diagnostic { +func DiagnosticToLSPPush(ctx context.Context, converters *Converters, diagnostic *ast.Diagnostic, reportStyleChecksAsWarnings bool) *lsproto.Diagnostic { clientCaps := lsproto.GetClientCapabilities(ctx) clientDiagnosticCaps := clientCaps.TextDocument.PublishDiagnostics return diagnosticToLSP(ctx, converters, diagnostic, diagnosticOptions{ - relatedInformation: clientDiagnosticCaps.RelatedInformation, - tagValueSet: clientDiagnosticCaps.TagSupport.ValueSet, - visualStudio: clientCaps.VSSupportsVisualStudioExtensions, + reportStyleChecksAsWarnings: reportStyleChecksAsWarnings, + relatedInformation: clientDiagnosticCaps.RelatedInformation, + tagValueSet: clientDiagnosticCaps.TagSupport.ValueSet, + visualStudio: clientCaps.VSSupportsVisualStudioExtensions, }) } diff --git a/tsc/internal/lsp/server.go b/tsc/internal/lsp/server.go index 2c744183ecc96..b3bf255b89ab1 100644 --- a/tsc/internal/lsp/server.go +++ b/tsc/internal/lsp/server.go @@ -1729,19 +1729,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, - RunExternalCode: runExternalCode, + 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, + RunExternalCode: runExternalCode, }, FS: s.fs, Logger: s.logger, diff --git a/tsc/internal/lsp/server_pushdiagnostics_test.go b/tsc/internal/lsp/server_pushdiagnostics_test.go new file mode 100644 index 0000000000000..4443877024371 --- /dev/null +++ b/tsc/internal/lsp/server_pushdiagnostics_test.go @@ -0,0 +1,137 @@ +package lsp_test + +import ( + "context" + "io" + "sync" + "testing" + + "github.com/microsoft/TypeScript/tsc/internal/bundled" + "github.com/microsoft/TypeScript/tsc/internal/lsp" + "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" + "github.com/microsoft/TypeScript/tsc/internal/testutil/lsptestutil" + "github.com/microsoft/TypeScript/tsc/internal/vfs/vfstest" + "gotest.tools/v3/assert" +) + +// TestPushFileDiagnosticsGate verifies the initialization gate for per-file push +// diagnostics: clients without pull-diagnostics support receive them, while +// pull-capable clients and clients that set disablePushDiagnostics do not. +func TestPushFileDiagnosticsGate(t *testing.T) { + t.Parallel() + if !bundled.Embedded { + t.Skip("bundled files are not embedded") + } + + const fileContent = `const x: number = "";` + files := map[string]string{ + "/home/project/tsconfig.json": `{}`, + "/home/project/index.ts": fileContent, + } + uri := lsproto.DocumentUri("file:///home/project/index.ts") + + // run initializes a server with the given capabilities and options, opens the + // file, waits for background tasks and a transport round trip, and returns the + // publishDiagnostics params received for the file. + run := func(t *testing.T, caps *lsproto.ClientCapabilities, initializationOptions *lsproto.InitializationOptions) []*lsproto.PublishDiagnosticsParams { + onServerRequest := func(_ context.Context, req *lsproto.RequestMessage) *lsproto.ResponseMessage { + switch req.Method { + case lsproto.MethodWorkspaceConfiguration: + return &lsproto.ResponseMessage{ID: req.ID, JSONRPC: req.JSONRPC, Result: []any{nil, nil, nil, nil}} + case lsproto.MethodClientRegisterCapability, lsproto.MethodClientUnregisterCapability: + return &lsproto.ResponseMessage{ID: req.ID, JSONRPC: req.JSONRPC, Result: lsproto.Null{}} + default: + return nil + } + } + + fs := bundled.WrapFS(vfstest.FromMap(files, false)) + client, closeClient := lsptestutil.NewLSPClient(t, lsp.ServerOptions{ + Err: io.Discard, + Cwd: "/home/project", + FS: fs, + DefaultLibraryPath: bundled.LibPath(), + }, onServerRequest) + t.Cleanup(func() { assert.NilError(t, closeClient()) }) + + var mu sync.Mutex + var published []*lsproto.PublishDiagnosticsParams + client.OnServerNotification = func(_ context.Context, req *lsproto.RequestMessage) { + if req.Method == lsproto.MethodTextDocumentPublishDiagnostics { + if params, err := lsproto.UnmarshalParams[*lsproto.PublishDiagnosticsParams](req); err == nil && params.Uri == uri { + mu.Lock() + published = append(published, params) + mu.Unlock() + } + } + } + + var initOptionsOrNull *lsproto.InitializationOptionsOrNull + if initializationOptions != nil { + initOptionsOrNull = &lsproto.InitializationOptionsOrNull{InitializationOptions: initializationOptions} + } + initMsg, _, ok := lsptestutil.SendRequest(t, client, lsproto.InitializeInfo, &lsproto.InitializeParams{ + Capabilities: caps, + InitializationOptions: initOptionsOrNull, + }) + assert.Assert(t, ok && initMsg.AsResponse().Error == nil, "initialize failed") + lsptestutil.SendNotification(t, client, lsproto.InitializedInfo, &lsproto.InitializedParams{}) + <-client.Server.InitComplete() + + lsptestutil.SendNotification(t, client, lsproto.TextDocumentDidOpenInfo, &lsproto.DidOpenTextDocumentParams{ + TextDocument: &lsproto.TextDocumentItem{Uri: uri, LanguageId: "typescript", Version: 1, Text: fileContent}, + }) + + // A request round trip ensures the didOpen has been processed. + msg, _, ok := lsptestutil.SendRequest(t, client, lsproto.CustomProjectInfoInfo, &lsproto.ProjectInfoParams{ + TextDocument: lsproto.TextDocumentIdentifier{Uri: uri}, + }) + assert.Assert(t, ok && msg.AsResponse().Error == nil) + client.Server.Session().WaitForBackgroundTasks() + + // Another round trip drains notifications written by the background tasks, + // since the client router processes the ordered output stream sequentially. + msg, _, ok = lsptestutil.SendRequest(t, client, lsproto.CustomProjectInfoInfo, &lsproto.ProjectInfoParams{ + TextDocument: lsproto.TextDocumentIdentifier{Uri: uri}, + }) + assert.Assert(t, ok && msg.AsResponse().Error == nil) + + mu.Lock() + defer mu.Unlock() + return append([]*lsproto.PublishDiagnosticsParams(nil), published...) + } + + pushOnlyCaps := &lsproto.ClientCapabilities{ + TextDocument: &lsproto.TextDocumentClientCapabilities{ + PublishDiagnostics: &lsproto.PublishDiagnosticsClientCapabilities{}, + }, + } + + t.Run("push-only client receives file diagnostics", func(t *testing.T) { + t.Parallel() + published := run(t, pushOnlyCaps, nil) + assert.Assert(t, len(published) > 0, "expected publishDiagnostics for the file") + last := published[len(published)-1] + assert.Equal(t, len(last.Diagnostics), 1) + }) + + t.Run("pull-capable client receives no file diagnostics pushes", func(t *testing.T) { + t.Parallel() + pullCaps := &lsproto.ClientCapabilities{ + TextDocument: &lsproto.TextDocumentClientCapabilities{ + PublishDiagnostics: &lsproto.PublishDiagnosticsClientCapabilities{}, + Diagnostic: &lsproto.DiagnosticClientCapabilities{}, + }, + } + published := run(t, pullCaps, nil) + assert.Equal(t, len(published), 0) + }) + + t.Run("disablePushDiagnostics disables file diagnostics pushes", func(t *testing.T) { + t.Parallel() + published := run(t, pushOnlyCaps, &lsproto.InitializationOptions{ + DisablePushDiagnostics: new(true), + }) + assert.Equal(t, len(published), 0) + }) +} diff --git a/tsc/internal/project/project_test.go b/tsc/internal/project/project_test.go index 71a83a522be7b..dc18822a3a5c2 100644 --- a/tsc/internal/project/project_test.go +++ b/tsc/internal/project/project_test.go @@ -834,3 +834,180 @@ 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) + }) + + t.Run("republishes when the validation preference changes", 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") + assert.Equal(t, len(calls[len(calls)-1].Params.Diagnostics), 1) + + // Disabling validation does not update programs, so diagnostics must + // still be cleared. + prefs := lsutil.NewDefaultUserPreferences() + prefs.EnableValidation = core.TSFalse + session.Configure(prefs) + session.WaitForBackgroundTasks() + + calls = filterDiagnosticsByURI(utils.Client().PublishDiagnosticsCalls(), uri, 0) + last := calls[len(calls)-1] + assert.Equal(t, len(last.Params.Diagnostics), 0) + + // Re-enabling validation republishes the existing errors. + callsBefore := len(utils.Client().PublishDiagnosticsCalls()) + prefs = lsutil.NewDefaultUserPreferences() + prefs.EnableValidation = core.TSTrue + session.Configure(prefs) + session.WaitForBackgroundTasks() + + calls = filterDiagnosticsByURI(utils.Client().PublishDiagnosticsCalls(), uri, callsBefore) + assert.Assert(t, len(calls) > 0, "expected PublishDiagnostics call after re-enabling validation") + assert.Equal(t, len(calls[len(calls)-1].Params.Diagnostics), 1) + }) + + t.Run("does not publish stale diagnostics after rapid changes and 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() + + // Rapid changes followed by an immediate close, without waiting in + // between: no publish for a stale version may arrive after the clear. + for version := int32(2); version <= 6; version++ { + session.DidChangeFile(context.Background(), uri, version, []lsproto.TextDocumentContentChangePartialOrWholeDocument{ + {WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: `const x: number = "";`}}, + }) + } + session.DidCloseFile(context.Background(), uri) + session.WaitForBackgroundTasks() + + calls := filterDiagnosticsByURI(utils.Client().PublishDiagnosticsCalls(), uri, 0) + assert.Assert(t, len(calls) > 0, "expected PublishDiagnostics calls for index.ts") + last := calls[len(calls)-1] + assert.Equal(t, len(last.Params.Diagnostics), 0) + assert.Assert(t, last.Params.Version == nil) + }) +} diff --git a/tsc/internal/project/session.go b/tsc/internal/project/session.go index d19fa70d16645..51c6067e6a849 100644 --- a/tsc/internal/project/session.go +++ b/tsc/internal/project/session.go @@ -42,6 +42,8 @@ const ( UpdateReasonUnknown UpdateReason = iota UpdateReasonDidOpenFile UpdateReasonDidCloseFile + UpdateReasonDidChangeFile + UpdateReasonDiagnosticsRefresh UpdateReasonDidChangeCompilerOptionsForInferredProjects UpdateReasonRequestedLanguageServicePendingChanges UpdateReasonRequestedLanguageServiceProjectNotLoaded @@ -74,6 +76,9 @@ type SessionOptions struct { LoggingEnabled bool TelemetryEnabled bool PushDiagnosticsEnabled bool + // PushFileDiagnosticsEnabled pushes per-file diagnostics for open files, + // for clients that do not support pull diagnostics. + PushFileDiagnosticsEnabled bool // RunExternalCode allows configured content mappers to run their (external) processes, // gated on workspace trust by the client. It corresponds to the --runExternalCode CLI flag. RunExternalCode bool @@ -211,6 +216,26 @@ 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 + + // publishFileDiagnosticsMu serializes open-file diagnostics publishes, which + // otherwise run on unordered background tasks. It guards the two fields below. + publishFileDiagnosticsMu sync.Mutex + // publishedFileDiagnosticsSnapshot is the ID of the newest snapshot whose + // open-file diagnostics have been published; older snapshots are skipped. + publishedFileDiagnosticsSnapshot uint64 + // publishedFileDiagnostics tracks the open files considered by the last + // publish pass and whether their last published diagnostics were non-empty, + // so closed files are cleared and redundant empty publishes are skipped. + publishedFileDiagnostics map[tspath.Path]publishedFileDiagnosticsState +} + +type publishedFileDiagnosticsState struct { + uri lsproto.DocumentUri + nonEmpty bool +} + +func (s *Session) pushFileDiagnosticsEnabled() bool { + return s.options.PushDiagnosticsEnabled && s.options.PushFileDiagnosticsEnabled } // newContentMapperHost creates the session's shared content mapper host when the workspace is trusted and @@ -249,20 +274,21 @@ func NewSession(init *SessionInit) *Session { sessionLogger = logging.NewNopLogger() } session := &Session{ - backgroundCtx: init.BackgroundCtx, - options: init.Options, - toPath: toPath, - client: init.Client, - logger: sessionLogger, - npmExecutor: init.NpmExecutor, - contentMapperHost: newContentMapperHost(init), - fs: overlayFS, - parseCache: parseCache, - contentMappedParseCache: contentMappedParseCache, - extendedConfigCache: extendedConfigCache, - programCounter: &programCounter{}, - backgroundQueue: background.NewQueue(), - startTime: time.Now(), + backgroundCtx: init.BackgroundCtx, + options: init.Options, + toPath: toPath, + client: init.Client, + logger: sessionLogger, + npmExecutor: init.NpmExecutor, + contentMapperHost: newContentMapperHost(init), + fs: overlayFS, + parseCache: parseCache, + contentMappedParseCache: contentMappedParseCache, + extendedConfigCache: extendedConfigCache, + programCounter: &programCounter{}, + backgroundQueue: background.NewQueue(), + startTime: time.Now(), + publishedFileDiagnostics: make(map[tspath.Path]publishedFileDiagnosticsState), snapshot: NewSnapshot( uint64(0), &SnapshotFS{ @@ -447,6 +473,13 @@ func (s *Session) DidChangeFile(ctx context.Context, uri lsproto.DocumentUri, ve }) s.pendingFileChangesMu.Unlock() + if s.pushFileDiagnosticsEnabled() { + // Push-only clients get diagnostics from snapshot updates rather than + // client-side re-pulls, so schedule an update instead of a refresh. + s.ScheduleSnapshotUpdate(UpdateReasonDidChangeFile) + return + } + // Editing a content-mapped file changes the program like any source edit, but the client's // pull-diagnostics machinery won't re-request diagnostics for dependent files: the content-mapped file is not // in the diagnostic provider's document selector, so a change to it never triggers the client's @@ -568,6 +601,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.scheduleDiagnosticsRefresh(s.options.DebounceDelay) } @@ -690,12 +729,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) }) } @@ -1479,6 +1529,7 @@ func (s *Session) updateSnapshot(ctx context.Context, overlays map[tspath.Path]* } _ = s.updateContentMapperRegistrations(ctx, newSnapshot) s.publishProgramDiagnostics(oldSnapshot, newSnapshot) + s.publishOpenFileDiagnostics(change, oldSnapshot, newSnapshot) s.sendProjectInfoTelemetryForNewProjects(oldSnapshot, newSnapshot) s.warmAutoImportCache(ctx, change, oldSnapshot, newSnapshot) }) @@ -1997,7 +2048,7 @@ func (s *Session) publishProjectDiagnostics(ctx context.Context, configFilePath ctx = s.withCurrentLocale(ctx) lspDiagnostics := make([]*lsproto.Diagnostic, 0, len(diagnostics)) for _, diag := range diagnostics { - lspDiagnostics = append(lspDiagnostics, lsconv.DiagnosticToLSPPush(ctx, converters, diag)) + lspDiagnostics = append(lspDiagnostics, lsconv.DiagnosticToLSPPush(ctx, converters, diag, false /*reportStyleChecksAsWarnings*/)) } if err := s.client.PublishDiagnostics(ctx, &lsproto.PublishDiagnosticsParams{ @@ -2008,6 +2059,87 @@ 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(change SnapshotChange, oldSnapshot *Snapshot, newSnapshot *Snapshot) { + if !s.pushFileDiagnosticsEnabled() { + return + } + + // Snapshot side effects run as unordered background tasks, so hold the mutex + // for the whole pass to keep publishes ordered, and skip snapshots older than + // one whose diagnostics were already published. + s.publishFileDiagnosticsMu.Lock() + defer s.publishFileDiagnosticsMu.Unlock() + if newSnapshot.ID() <= s.publishedFileDiagnosticsSnapshot { + return + } + prevPublished := s.publishedFileDiagnosticsSnapshot + s.publishedFileDiagnosticsSnapshot = newSnapshot.ID() + + // Semantic and suggestion diagnostics are a diagnostics workload, matching + // the checker the pull handler uses instead of consuming query checkers. + ctx := core.WithCheckerLifetime(s.backgroundContext(), core.CheckerLifetimeDiagnostics) + + for path, state := range s.publishedFileDiagnostics { + if _, stillOpen := newSnapshot.fs.overlays[path]; !stillOpen { + delete(s.publishedFileDiagnostics, path) + if state.nonEmpty { + s.publishFileDiagnostics(ctx, state.uri, nil, nil) + } + } + } + + // Preference changes (e.g. enableValidation) and diagnostics refreshes affect + // diagnostics without updating programs, so they republish every open file. + force := change.newConfig != nil || change.reason == UpdateReasonDiagnosticsRefresh + + 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 + } + // Skip files already considered whose program has not updated since the + // last publish; prevPublished (not the parent snapshot) is the reference + // because intermediate snapshots may have been skipped. + state, seen := s.publishedFileDiagnostics[path] + if !force && seen && project.ProgramLastUpdate <= prevPublished { + continue + } + languageService := ls.NewLanguageService(project.configFilePath, program, newSnapshot, overlay.FileName()) + diagnostics := languageService.ProvidePushDiagnostics(ctx, uri) + nonEmpty := len(diagnostics) > 0 + s.publishedFileDiagnostics[path] = publishedFileDiagnosticsState{uri: uri, nonEmpty: nonEmpty} + if !nonEmpty && (!seen || !state.nonEmpty) { + // The file has no published diagnostics, so there is nothing to clear. + continue + } + 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/tsc/internal/project/snapshot.go b/tsc/internal/project/snapshot.go index 23d79f0c55419..01677977f4ef5 100644 --- a/tsc/internal/project/snapshot.go +++ b/tsc/internal/project/snapshot.go @@ -303,6 +303,10 @@ func (s *Snapshot) Clone( 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: