diff --git a/internal/fourslash/fourslash.go b/internal/fourslash/fourslash.go index 1c982f10f3..8c7cd9cf09 100644 --- a/internal/fourslash/fourslash.go +++ b/internal/fourslash/fourslash.go @@ -250,8 +250,14 @@ func (f *FourslashTest) nextID() int32 { } func (f *FourslashTest) initialize(t *testing.T, capabilities *lsproto.ClientCapabilities) { + initOptions := map[string]any{ + // Hack: disable push diagnostics entirely, since the fourslash runner does not + // yet gracefully handle non-request messages. + "disablePushDiagnostics": true, + } params := &lsproto.InitializeParams{ - Locale: ptrTo("en-US"), + Locale: ptrTo("en-US"), + InitializationOptions: ptrTo[any](initOptions), } params.Capabilities = getCapabilitiesWithDefaults(capabilities) // !!! check for errors? diff --git a/internal/ls/diagnostics.go b/internal/ls/diagnostics.go index f223bf5dbe..39ee512a22 100644 --- a/internal/ls/diagnostics.go +++ b/internal/ls/diagnostics.go @@ -2,12 +2,8 @@ package ls import ( "context" - "slices" - "strings" "github.com/microsoft/typescript-go/internal/ast" - "github.com/microsoft/typescript-go/internal/diagnostics" - "github.com/microsoft/typescript-go/internal/diagnosticwriter" "github.com/microsoft/typescript-go/internal/ls/lsconv" "github.com/microsoft/typescript-go/internal/lsp/lsproto" ) @@ -41,76 +37,8 @@ func (l *LanguageService) toLSPDiagnostics(ctx context.Context, diagnostics ...[ lspDiagnostics := make([]*lsproto.Diagnostic, 0, size) for _, diagSlice := range diagnostics { for _, diag := range diagSlice { - lspDiagnostics = append(lspDiagnostics, l.toLSPDiagnostic(ctx, diag)) + lspDiagnostics = append(lspDiagnostics, lsconv.DiagnosticToLSPPull(ctx, l.converters, diag)) } } return lspDiagnostics } - -func (l *LanguageService) toLSPDiagnostic(ctx context.Context, diagnostic *ast.Diagnostic) *lsproto.Diagnostic { - clientOptions := lsproto.GetClientCapabilities(ctx).TextDocument.Diagnostic - var severity lsproto.DiagnosticSeverity - switch diagnostic.Category() { - case diagnostics.CategorySuggestion: - severity = lsproto.DiagnosticSeverityHint - case diagnostics.CategoryMessage: - severity = lsproto.DiagnosticSeverityInformation - case diagnostics.CategoryWarning: - severity = lsproto.DiagnosticSeverityWarning - default: - severity = lsproto.DiagnosticSeverityError - } - - var relatedInformation []*lsproto.DiagnosticRelatedInformation - if clientOptions.RelatedInformation { - relatedInformation = make([]*lsproto.DiagnosticRelatedInformation, 0, len(diagnostic.RelatedInformation())) - for _, related := range diagnostic.RelatedInformation() { - relatedInformation = append(relatedInformation, &lsproto.DiagnosticRelatedInformation{ - Location: lsproto.Location{ - Uri: lsconv.FileNameToDocumentURI(related.File().FileName()), - Range: l.converters.ToLSPRange(related.File(), related.Loc()), - }, - Message: related.Message(), - }) - } - } - - var tags []lsproto.DiagnosticTag - if len(clientOptions.TagSupport.ValueSet) > 0 && (diagnostic.ReportsUnnecessary() || diagnostic.ReportsDeprecated()) { - tags = make([]lsproto.DiagnosticTag, 0, 2) - if diagnostic.ReportsUnnecessary() && slices.Contains(clientOptions.TagSupport.ValueSet, lsproto.DiagnosticTagUnnecessary) { - tags = append(tags, lsproto.DiagnosticTagUnnecessary) - } - if diagnostic.ReportsDeprecated() && slices.Contains(clientOptions.TagSupport.ValueSet, lsproto.DiagnosticTagDeprecated) { - tags = append(tags, lsproto.DiagnosticTagDeprecated) - } - } - - return &lsproto.Diagnostic{ - Range: l.converters.ToLSPRange(diagnostic.File(), diagnostic.Loc()), - Code: &lsproto.IntegerOrString{ - Integer: ptrTo(diagnostic.Code()), - }, - Severity: &severity, - Message: messageChainToString(diagnostic), - Source: ptrTo("ts"), - RelatedInformation: ptrToSliceIfNonEmpty(relatedInformation), - Tags: ptrToSliceIfNonEmpty(tags), - } -} - -func messageChainToString(diagnostic *ast.Diagnostic) string { - if len(diagnostic.MessageChain()) == 0 { - return diagnostic.Message() - } - var b strings.Builder - diagnosticwriter.WriteFlattenedASTDiagnosticMessage(&b, diagnostic, "\n") - return b.String() -} - -func ptrToSliceIfNonEmpty[T any](s []T) *[]T { - if len(s) == 0 { - return nil - } - return &s -} diff --git a/internal/ls/lsconv/converters.go b/internal/ls/lsconv/converters.go index 3b290b446c..4370c2b056 100644 --- a/internal/ls/lsconv/converters.go +++ b/internal/ls/lsconv/converters.go @@ -1,6 +1,7 @@ package lsconv import ( + "context" "fmt" "net/url" "slices" @@ -8,7 +9,10 @@ import ( "unicode/utf16" "unicode/utf8" + "github.com/microsoft/typescript-go/internal/ast" "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/diagnostics" + "github.com/microsoft/typescript-go/internal/diagnosticwriter" "github.com/microsoft/typescript-go/internal/lsp/lsproto" "github.com/microsoft/typescript-go/internal/tspath" ) @@ -199,3 +203,99 @@ func (c *Converters) PositionToLineAndCharacter(script Script, position core.Tex func ptrTo[T any](v T) *T { return &v } + +type diagnosticCapabilities struct { + relatedInformation bool + tagValueSet []lsproto.DiagnosticTag +} + +// DiagnosticToLSPPull converts a diagnostic for pull diagnostics (textDocument/diagnostic) +func DiagnosticToLSPPull(ctx context.Context, converters *Converters, diagnostic *ast.Diagnostic) *lsproto.Diagnostic { + clientCaps := lsproto.GetClientCapabilities(ctx).TextDocument.Diagnostic + return diagnosticToLSP(converters, diagnostic, diagnosticCapabilities{ + relatedInformation: clientCaps.RelatedInformation, + tagValueSet: clientCaps.TagSupport.ValueSet, + }) +} + +// DiagnosticToLSPPush converts a diagnostic for push diagnostics (textDocument/publishDiagnostics) +func DiagnosticToLSPPush(ctx context.Context, converters *Converters, diagnostic *ast.Diagnostic) *lsproto.Diagnostic { + clientCaps := lsproto.GetClientCapabilities(ctx).TextDocument.PublishDiagnostics + return diagnosticToLSP(converters, diagnostic, diagnosticCapabilities{ + relatedInformation: clientCaps.RelatedInformation, + tagValueSet: clientCaps.TagSupport.ValueSet, + }) +} + +func diagnosticToLSP(converters *Converters, diagnostic *ast.Diagnostic, caps diagnosticCapabilities) *lsproto.Diagnostic { + var severity lsproto.DiagnosticSeverity + switch diagnostic.Category() { + case diagnostics.CategorySuggestion: + severity = lsproto.DiagnosticSeverityHint + case diagnostics.CategoryMessage: + severity = lsproto.DiagnosticSeverityInformation + case diagnostics.CategoryWarning: + severity = lsproto.DiagnosticSeverityWarning + default: + severity = lsproto.DiagnosticSeverityError + } + + var relatedInformation []*lsproto.DiagnosticRelatedInformation + if caps.relatedInformation { + relatedInformation = make([]*lsproto.DiagnosticRelatedInformation, 0, len(diagnostic.RelatedInformation())) + for _, related := range diagnostic.RelatedInformation() { + relatedInformation = append(relatedInformation, &lsproto.DiagnosticRelatedInformation{ + Location: lsproto.Location{ + Uri: FileNameToDocumentURI(related.File().FileName()), + Range: converters.ToLSPRange(related.File(), related.Loc()), + }, + Message: related.Message(), + }) + } + } + + var tags []lsproto.DiagnosticTag + if len(caps.tagValueSet) > 0 && (diagnostic.ReportsUnnecessary() || diagnostic.ReportsDeprecated()) { + tags = make([]lsproto.DiagnosticTag, 0, 2) + if diagnostic.ReportsUnnecessary() && slices.Contains(caps.tagValueSet, lsproto.DiagnosticTagUnnecessary) { + tags = append(tags, lsproto.DiagnosticTagUnnecessary) + } + if diagnostic.ReportsDeprecated() && slices.Contains(caps.tagValueSet, lsproto.DiagnosticTagDeprecated) { + tags = append(tags, lsproto.DiagnosticTagDeprecated) + } + } + + // For diagnostics without a file (e.g., program diagnostics), use a zero range + var lspRange lsproto.Range + if diagnostic.File() != nil { + lspRange = converters.ToLSPRange(diagnostic.File(), diagnostic.Loc()) + } + + return &lsproto.Diagnostic{ + Range: lspRange, + Code: &lsproto.IntegerOrString{ + Integer: ptrTo(diagnostic.Code()), + }, + Severity: &severity, + Message: messageChainToString(diagnostic), + Source: ptrTo("ts"), + RelatedInformation: ptrToSliceIfNonEmpty(relatedInformation), + Tags: ptrToSliceIfNonEmpty(tags), + } +} + +func messageChainToString(diagnostic *ast.Diagnostic) string { + if len(diagnostic.MessageChain()) == 0 { + return diagnostic.Message() + } + var b strings.Builder + diagnosticwriter.WriteFlattenedASTDiagnosticMessage(&b, diagnostic, "\n") + return b.String() +} + +func ptrToSliceIfNonEmpty[T any](s []T) *[]T { + if len(s) == 0 { + return nil + } + return &s +} diff --git a/internal/lsp/server.go b/internal/lsp/server.go index de880048c1..2c3eafa3a7 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -219,6 +219,13 @@ func (s *Server) RefreshDiagnostics(ctx context.Context) error { return nil } +// PublishDiagnostics implements project.Client. +func (s *Server) PublishDiagnostics(ctx context.Context, params *lsproto.PublishDiagnosticsParams) error { + notification := lsproto.TextDocumentPublishDiagnosticsInfo.NewNotificationMessage(params) + s.outgoingQueue <- notification.Message() + return nil +} + func (s *Server) RequestConfiguration(ctx context.Context) (*lsutil.UserPreferences, error) { caps := lsproto.GetClientCapabilities(ctx) if !caps.Workspace.Configuration { @@ -716,15 +723,26 @@ func (s *Server) handleInitialized(ctx context.Context, params *lsproto.Initiali cwd = s.cwd } + var disablePushDiagnostics bool + if s.initializeParams != nil && s.initializeParams.InitializationOptions != nil && *s.initializeParams.InitializationOptions != nil { + // Check for disablePushDiagnostics option + if initOpts, ok := (*s.initializeParams.InitializationOptions).(map[string]any); ok { + if disable, ok := initOpts["disablePushDiagnostics"].(bool); ok { + disablePushDiagnostics = disable + } + } + } + s.session = project.NewSession(&project.SessionInit{ Options: &project.SessionOptions{ - CurrentDirectory: cwd, - DefaultLibraryPath: s.defaultLibraryPath, - TypingsLocation: s.typingsLocation, - PositionEncoding: s.positionEncoding, - WatchEnabled: s.watchEnabled, - LoggingEnabled: true, - DebounceDelay: 500 * time.Millisecond, + CurrentDirectory: cwd, + DefaultLibraryPath: s.defaultLibraryPath, + TypingsLocation: s.typingsLocation, + PositionEncoding: s.positionEncoding, + WatchEnabled: s.watchEnabled, + LoggingEnabled: true, + DebounceDelay: 500 * time.Millisecond, + PushDiagnosticsEnabled: !disablePushDiagnostics, }, FS: s.fs, Logger: s.logger, @@ -733,36 +751,28 @@ func (s *Server) handleInitialized(ctx context.Context, params *lsproto.Initiali ParseCache: s.parseCache, }) - if s.initializeParams != nil && s.initializeParams.InitializationOptions != nil && *s.initializeParams.InitializationOptions != nil { - // handle userPreferences from initializationOptions - userPreferences := s.session.NewUserPreferences() - userPreferences.Parse(*s.initializeParams.InitializationOptions) - s.session.InitializeWithConfig(userPreferences) - } else { - // request userPreferences if not provided at initialization - userPreferences, err := s.RequestConfiguration(ctx) - if err != nil { - return err - } - s.session.InitializeWithConfig(userPreferences) + userPreferences, err := s.RequestConfiguration(ctx) + if err != nil { + return err + } + s.session.InitializeWithConfig(userPreferences) - _, err = sendClientRequest(ctx, s, lsproto.ClientRegisterCapabilityInfo, &lsproto.RegistrationParams{ - Registrations: []*lsproto.Registration{ - { - Id: "typescript-config-watch-id", - Method: string(lsproto.MethodWorkspaceDidChangeConfiguration), - RegisterOptions: ptrTo(any(lsproto.DidChangeConfigurationRegistrationOptions{ - Section: &lsproto.StringOrStrings{ - // !!! Both the 'javascript' and 'js/ts' scopes need to be watched for settings as well. - Strings: &[]string{"typescript"}, - }, - })), - }, + _, err = sendClientRequest(ctx, s, lsproto.ClientRegisterCapabilityInfo, &lsproto.RegistrationParams{ + Registrations: []*lsproto.Registration{ + { + Id: "typescript-config-watch-id", + Method: string(lsproto.MethodWorkspaceDidChangeConfiguration), + RegisterOptions: ptrTo(any(lsproto.DidChangeConfigurationRegistrationOptions{ + Section: &lsproto.StringOrStrings{ + // !!! Both the 'javascript' and 'js/ts' scopes need to be watched for settings as well. + Strings: &[]string{"typescript"}, + }, + })), }, - }) - if err != nil { - return fmt.Errorf("failed to register configuration change watcher: %w", err) - } + }, + }) + if err != nil { + return fmt.Errorf("failed to register configuration change watcher: %w", err) } // !!! temporary. diff --git a/internal/project/client.go b/internal/project/client.go index e223389eb6..46b5393a05 100644 --- a/internal/project/client.go +++ b/internal/project/client.go @@ -10,4 +10,5 @@ type Client interface { WatchFiles(ctx context.Context, id WatcherID, watchers []*lsproto.FileSystemWatcher) error UnwatchFiles(ctx context.Context, id WatcherID) error RefreshDiagnostics(ctx context.Context) error + PublishDiagnostics(ctx context.Context, params *lsproto.PublishDiagnosticsParams) error } diff --git a/internal/project/project_test.go b/internal/project/project_test.go index 90ad9cda64..d4042f5d57 100644 --- a/internal/project/project_test.go +++ b/internal/project/project_test.go @@ -175,3 +175,137 @@ func TestProject(t *testing.T) { assert.NilError(t, err) }) } + +func TestPushDiagnostics(t *testing.T) { + t.Parallel() + if !bundled.Embedded { + t.Skip("bundled files are not embedded") + } + + t.Run("publishes program diagnostics on initial program creation", func(t *testing.T) { + t.Parallel() + files := map[string]any{ + "/src/tsconfig.json": `{"compilerOptions": {"baseUrl": "."}}`, + "/src/index.ts": "export const x = 1;", + } + session, utils := projecttestutil.Setup(files) + session.DidOpenFile(context.Background(), "file:///src/index.ts", 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript) + _, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts")) + assert.NilError(t, err) + + session.WaitForBackgroundTasks() + + calls := utils.Client().PublishDiagnosticsCalls() + assert.Assert(t, len(calls) > 0, "expected at least one PublishDiagnostics call") + + // Find the call for tsconfig.json + var tsconfigCall *struct { + Ctx context.Context + Params *lsproto.PublishDiagnosticsParams + } + for i := range calls { + if calls[i].Params.Uri == "file:///src/tsconfig.json" { + tsconfigCall = &calls[i] + break + } + } + assert.Assert(t, tsconfigCall != nil, "expected PublishDiagnostics call for tsconfig.json") + assert.Assert(t, len(tsconfigCall.Params.Diagnostics) > 0, "expected at least one diagnostic") + }) + + t.Run("clears diagnostics when project is removed", func(t *testing.T) { + t.Parallel() + files := map[string]any{ + "/src/tsconfig.json": `{"compilerOptions": {"baseUrl": "."}}`, + "/src/index.ts": "export const x = 1;", + "/src2/tsconfig.json": `{"compilerOptions": {}}`, + "/src2/index.ts": "export const y = 2;", + } + session, utils := projecttestutil.Setup(files) + session.DidOpenFile(context.Background(), "file:///src/index.ts", 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript) + _, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts")) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + + // Open a file in a different project to trigger cleanup of the first + session.DidCloseFile(context.Background(), "file:///src/index.ts") + session.DidOpenFile(context.Background(), "file:///src2/index.ts", 1, files["/src2/index.ts"].(string), lsproto.LanguageKindTypeScript) + _, err = session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src2/index.ts")) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + + calls := utils.Client().PublishDiagnosticsCalls() + // Should have at least one call for the first project with diagnostics, + // and one clearing it after switching projects + var firstProjectCalls []struct { + Ctx context.Context + Params *lsproto.PublishDiagnosticsParams + } + for i := range calls { + if calls[i].Params.Uri == "file:///src/tsconfig.json" { + firstProjectCalls = append(firstProjectCalls, calls[i]) + } + } + assert.Assert(t, len(firstProjectCalls) >= 2, "expected at least 2 PublishDiagnostics calls for first project") + // Last call should clear diagnostics + lastCall := firstProjectCalls[len(firstProjectCalls)-1] + assert.Equal(t, len(lastCall.Params.Diagnostics), 0, "expected empty diagnostics after project cleanup") + }) + + t.Run("updates diagnostics when program changes", func(t *testing.T) { + t.Parallel() + files := map[string]any{ + "/src/tsconfig.json": `{"compilerOptions": {"baseUrl": "."}}`, + "/src/index.ts": "export const x = 1;", + } + session, utils := projecttestutil.Setup(files) + session.DidOpenFile(context.Background(), "file:///src/index.ts", 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript) + _, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts")) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + + initialCallCount := len(utils.Client().PublishDiagnosticsCalls()) + + // Change the tsconfig to remove baseUrl + err = utils.FS().WriteFile("/src/tsconfig.json", `{"compilerOptions": {}}`, false) + assert.NilError(t, err) + session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{{Uri: lsproto.DocumentUri("file:///src/tsconfig.json"), Type: lsproto.FileChangeTypeChanged}}) + _, err = session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts")) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + + calls := utils.Client().PublishDiagnosticsCalls() + assert.Assert(t, len(calls) > initialCallCount, "expected additional PublishDiagnostics call after change") + + // Find the last call for tsconfig.json + var lastTsconfigCall *struct { + Ctx context.Context + Params *lsproto.PublishDiagnosticsParams + } + for i := len(calls) - 1; i >= 0; i-- { + if calls[i].Params.Uri == "file:///src/tsconfig.json" { + lastTsconfigCall = &calls[i] + break + } + } + assert.Assert(t, lastTsconfigCall != nil, "expected PublishDiagnostics call for tsconfig.json") + // After fixing the error, there should be no program diagnostics + assert.Equal(t, len(lastTsconfigCall.Params.Diagnostics), 0, "expected no diagnostics after removing baseUrl option") + }) + + t.Run("does not publish for inferred projects", func(t *testing.T) { + t.Parallel() + files := map[string]any{ + "/src/index.ts": "let x: number = 'not a number';", + } + session, utils := projecttestutil.Setup(files) + session.DidOpenFile(context.Background(), "file:///src/index.ts", 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript) + _, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts")) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + + calls := utils.Client().PublishDiagnosticsCalls() + // Should not have any calls since inferred projects don't have tsconfig.json + assert.Equal(t, len(calls), 0, "expected no PublishDiagnostics calls for inferred projects") + }) +} diff --git a/internal/project/session.go b/internal/project/session.go index 0e2b2de6b1..4adaf13e58 100644 --- a/internal/project/session.go +++ b/internal/project/session.go @@ -14,6 +14,7 @@ import ( "github.com/microsoft/typescript-go/internal/compiler" "github.com/microsoft/typescript-go/internal/core" "github.com/microsoft/typescript-go/internal/ls" + "github.com/microsoft/typescript-go/internal/ls/lsconv" "github.com/microsoft/typescript-go/internal/ls/lsutil" "github.com/microsoft/typescript-go/internal/lsp/lsproto" "github.com/microsoft/typescript-go/internal/project/ata" @@ -37,13 +38,14 @@ const ( // SessionOptions are the immutable initialization options for a session. // Snapshots may reference them as a pointer since they never change. type SessionOptions struct { - CurrentDirectory string - DefaultLibraryPath string - TypingsLocation string - PositionEncoding lsproto.PositionEncodingKind - WatchEnabled bool - LoggingEnabled bool - DebounceDelay time.Duration + CurrentDirectory string + DefaultLibraryPath string + TypingsLocation string + PositionEncoding lsproto.PositionEncodingKind + WatchEnabled bool + LoggingEnabled bool + PushDiagnosticsEnabled bool + DebounceDelay time.Duration } type SessionInit struct { @@ -146,7 +148,6 @@ func NewSession(init *SessionInit) *Session { extendedConfigCache: extendedConfigCache, programCounter: &programCounter{}, backgroundQueue: background.NewQueue(), - snapshotID: atomic.Uint64{}, snapshot: NewSnapshot( uint64(0), &SnapshotFS{ @@ -439,6 +440,7 @@ func (s *Session) UpdateSnapshot(ctx context.Context, overlays map[tspath.Path]* s.logger.Log(err) } } + s.publishProgramDiagnostics(oldSnapshot, newSnapshot) }) return newSnapshot @@ -689,6 +691,57 @@ func (s *Session) NpmInstall(cwd string, npmInstallArgs []string) ([]byte, error return s.npmExecutor.NpmInstall(cwd, npmInstallArgs) } +func (s *Session) publishProgramDiagnostics(oldSnapshot *Snapshot, newSnapshot *Snapshot) { + if !s.options.PushDiagnosticsEnabled { + return + } + + ctx := context.Background() + collections.DiffOrderedMaps( + oldSnapshot.ProjectCollection.ProjectsByPath(), + newSnapshot.ProjectCollection.ProjectsByPath(), + func(configFilePath tspath.Path, addedProject *Project) { + if !shouldPublishProgramDiagnostics(addedProject, newSnapshot.ID()) { + return + } + s.publishProjectDiagnostics(ctx, string(configFilePath), addedProject.Program.GetProgramDiagnostics(), newSnapshot.converters) + }, + func(configFilePath tspath.Path, removedProject *Project) { + if removedProject.Kind != KindConfigured { + return + } + s.publishProjectDiagnostics(ctx, string(configFilePath), nil, oldSnapshot.converters) + }, + func(configFilePath tspath.Path, oldProject, newProject *Project) { + if !shouldPublishProgramDiagnostics(newProject, newSnapshot.ID()) { + return + } + s.publishProjectDiagnostics(ctx, string(configFilePath), newProject.Program.GetProgramDiagnostics(), newSnapshot.converters) + }, + ) +} + +func shouldPublishProgramDiagnostics(p *Project, snapshotID uint64) bool { + if p.Kind != KindConfigured || p.Program == nil || p.ProgramLastUpdate != snapshotID { + return false + } + return p.ProgramUpdateKind > ProgramUpdateKindCloned +} + +func (s *Session) publishProjectDiagnostics(ctx context.Context, configFilePath string, diagnostics []*ast.Diagnostic, converters *lsconv.Converters) { + lspDiagnostics := make([]*lsproto.Diagnostic, 0, len(diagnostics)) + for _, diag := range diagnostics { + lspDiagnostics = append(lspDiagnostics, lsconv.DiagnosticToLSPPush(ctx, converters, diag)) + } + + if err := s.client.PublishDiagnostics(ctx, &lsproto.PublishDiagnosticsParams{ + Uri: lsconv.FileNameToDocumentURI(configFilePath), + Diagnostics: lspDiagnostics, + }); err != nil && s.options.LoggingEnabled { + s.logger.Logf("Error publishing diagnostics: %v", err) + } +} + func (s *Session) triggerATAForUpdatedProjects(newSnapshot *Snapshot) { for _, project := range newSnapshot.ProjectCollection.Projects() { if project.ShouldTriggerATA(newSnapshot.ID()) { diff --git a/internal/testutil/projecttestutil/clientmock_generated.go b/internal/testutil/projecttestutil/clientmock_generated.go index f2dff8fad5..2c310ceb13 100644 --- a/internal/testutil/projecttestutil/clientmock_generated.go +++ b/internal/testutil/projecttestutil/clientmock_generated.go @@ -21,6 +21,9 @@ var _ project.Client = &ClientMock{} // // // make and configure a mocked project.Client // mockedClient := &ClientMock{ +// PublishDiagnosticsFunc: func(ctx context.Context, params *lsproto.PublishDiagnosticsParams) error { +// panic("mock out the PublishDiagnostics method") +// }, // RefreshDiagnosticsFunc: func(ctx context.Context) error { // panic("mock out the RefreshDiagnostics method") // }, @@ -37,6 +40,9 @@ var _ project.Client = &ClientMock{} // // } type ClientMock struct { + // PublishDiagnosticsFunc mocks the PublishDiagnostics method. + PublishDiagnosticsFunc func(ctx context.Context, params *lsproto.PublishDiagnosticsParams) error + // RefreshDiagnosticsFunc mocks the RefreshDiagnostics method. RefreshDiagnosticsFunc func(ctx context.Context) error @@ -48,6 +54,13 @@ type ClientMock struct { // calls tracks calls to the methods. calls struct { + // PublishDiagnostics holds details about calls to the PublishDiagnostics method. + PublishDiagnostics []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Params is the params argument value. + Params *lsproto.PublishDiagnosticsParams + } // RefreshDiagnostics holds details about calls to the RefreshDiagnostics method. RefreshDiagnostics []struct { // Ctx is the ctx argument value. @@ -70,11 +83,49 @@ type ClientMock struct { Watchers []*lsproto.FileSystemWatcher } } + lockPublishDiagnostics sync.RWMutex lockRefreshDiagnostics sync.RWMutex lockUnwatchFiles sync.RWMutex lockWatchFiles sync.RWMutex } +// PublishDiagnostics calls PublishDiagnosticsFunc. +func (mock *ClientMock) PublishDiagnostics(ctx context.Context, params *lsproto.PublishDiagnosticsParams) error { + callInfo := struct { + Ctx context.Context + Params *lsproto.PublishDiagnosticsParams + }{ + Ctx: ctx, + Params: params, + } + mock.lockPublishDiagnostics.Lock() + mock.calls.PublishDiagnostics = append(mock.calls.PublishDiagnostics, callInfo) + mock.lockPublishDiagnostics.Unlock() + if mock.PublishDiagnosticsFunc == nil { + var errOut error + return errOut + } + return mock.PublishDiagnosticsFunc(ctx, params) +} + +// PublishDiagnosticsCalls gets all the calls that were made to PublishDiagnostics. +// Check the length with: +// +// len(mockedClient.PublishDiagnosticsCalls()) +func (mock *ClientMock) PublishDiagnosticsCalls() []struct { + Ctx context.Context + Params *lsproto.PublishDiagnosticsParams +} { + var calls []struct { + Ctx context.Context + Params *lsproto.PublishDiagnosticsParams + } + mock.lockPublishDiagnostics.RLock() + calls = mock.calls.PublishDiagnostics + mock.lockPublishDiagnostics.RUnlock() + return calls +} + // RefreshDiagnostics calls RefreshDiagnosticsFunc. func (mock *ClientMock) RefreshDiagnostics(ctx context.Context) error { callInfo := struct { diff --git a/internal/testutil/projecttestutil/projecttestutil.go b/internal/testutil/projecttestutil/projecttestutil.go index 169709d42c..882a582f70 100644 --- a/internal/testutil/projecttestutil/projecttestutil.go +++ b/internal/testutil/projecttestutil/projecttestutil.go @@ -209,12 +209,13 @@ func SetupWithOptionsAndTypingsInstaller(files map[string]any, options *project. // Use provided options or create default ones if options == nil { options = &project.SessionOptions{ - CurrentDirectory: "/", - DefaultLibraryPath: bundled.LibPath(), - TypingsLocation: TestTypingsLocation, - PositionEncoding: lsproto.PositionEncodingKindUTF8, - WatchEnabled: true, - LoggingEnabled: true, + CurrentDirectory: "/", + DefaultLibraryPath: bundled.LibPath(), + TypingsLocation: TestTypingsLocation, + PositionEncoding: lsproto.PositionEncodingKindUTF8, + WatchEnabled: true, + LoggingEnabled: true, + PushDiagnosticsEnabled: true, } }