Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions internal/ls/diagnostics.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
29 changes: 19 additions & 10 deletions internal/lsp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
121 changes: 121 additions & 0 deletions internal/project/project_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
106 changes: 100 additions & 6 deletions internal/project/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ const (
UpdateReasonUnknown UpdateReason = iota
UpdateReasonDidOpenFile
UpdateReasonDidCloseFile
UpdateReasonDidChangeFile
UpdateReasonDiagnosticsRefresh
UpdateReasonDidChangeCompilerOptionsForInferredProjects
UpdateReasonRequestedLanguageServicePendingChanges
UpdateReasonRequestedLanguageServiceProjectNotLoaded
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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)
})
}

Expand Down Expand Up @@ -1237,6 +1270,7 @@ func (s *Session) updateSnapshot(ctx context.Context, overlays map[tspath.Path]*
}
}
s.publishProgramDiagnostics(oldSnapshot, newSnapshot)
s.publishOpenFileDiagnostics(oldSnapshot, newSnapshot)
Comment thread
christianvuerings marked this conversation as resolved.
s.sendProjectInfoTelemetryForNewProjects(oldSnapshot, newSnapshot)
s.warmAutoImportCache(ctx, change, oldSnapshot, newSnapshot)
})
Expand Down Expand Up @@ -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
}
Comment thread
christianvuerings marked this conversation as resolved.

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)
}
Comment thread
christianvuerings marked this conversation as resolved.
}

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.
Expand Down
4 changes: 4 additions & 0 deletions internal/project/snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down