diff --git a/internal/ls/lsutil/userpreferences.go b/internal/ls/lsutil/userpreferences.go index 8fc5146d634..06417371a2e 100644 --- a/internal/ls/lsutil/userpreferences.go +++ b/internal/ls/lsutil/userpreferences.go @@ -176,6 +176,7 @@ type UserPreferences struct { DisableLineTextInReferences core.Tristate `raw:"disableLineTextInReferences"` // !!! DisplayPartsForJSDoc core.Tristate `raw:"displayPartsForJSDoc"` // !!! ReportStyleChecksAsWarnings core.Tristate `raw:"reportStyleChecksAsWarnings" config:"reportStyleChecksAsWarnings"` + Locale string `config:"locale"` // ------- ATA ------- diff --git a/internal/ls/lsutil/userpreferences_test.go b/internal/ls/lsutil/userpreferences_test.go index 153f737b345..29d7ba9eec2 100644 --- a/internal/ls/lsutil/userpreferences_test.go +++ b/internal/ls/lsutil/userpreferences_test.go @@ -424,6 +424,21 @@ func TestUserPreferencesParseUnstable(t *testing.T) { } } +func TestUserPreferencesLocale(t *testing.T) { + t.Parallel() + + prefs := ParseUserPreferences(map[string]any{ + "typescript": map[string]any{ + "locale": "de", + }, + "js/ts": map[string]any{ + "locale": "fr", + }, + }) + + assert.Equal(t, prefs.Locale, "fr") +} + func TestUserPreferencesReportStyleChecksAsWarnings(t *testing.T) { t.Parallel() diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 8a0b7214c9d..6fed3a9783a 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -176,7 +176,11 @@ type Server struct { initializationOptions *lsproto.InitializationOptions clientCapabilities lsproto.ResolvedClientCapabilities positionEncoding lsproto.PositionEncodingKind + localeMu sync.RWMutex locale locale.Locale + // initLocale is the locale resolved from the initialize request; it is + // used as the fallback when the user's locale preference is "auto". + initLocale locale.Locale watchEnabled bool telemetryEnabled bool @@ -362,6 +366,28 @@ func (s *Server) ProgressFinish(message *diagnostics.Message, args ...any) { } } +// GetLocale implements project.Client. +func (s *Server) GetLocale() locale.Locale { + s.localeMu.RLock() + defer s.localeMu.RUnlock() + return s.locale +} + +// SetLocale implements project.Client. +func (s *Server) SetLocale(localeString string) { + newLocale := s.initLocale + if localeString != "auto" { + parsed, ok := locale.Parse(localeString) + if !ok { + return + } + newLocale = parsed + } + s.localeMu.Lock() + s.locale = newLocale + s.localeMu.Unlock() +} + func (s *Server) RequestConfiguration(ctx context.Context) (lsutil.UserPreferences, error) { caps := lsproto.GetClientCapabilities(ctx) if !caps.Workspace.Configuration { @@ -539,7 +565,7 @@ func (s *Server) dispatchLoop(ctx context.Context) error { } s.lastRequestTimeMs.Store(time.Now().UnixMilli()) - requestCtx := locale.WithLocale(ctx, s.locale) + requestCtx := locale.WithLocale(ctx, s.GetLocale()) var cancel context.CancelFunc if req.ID != nil { requestCtx, cancel = context.WithCancel(core.WithRequestID(requestCtx, req.ID.String())) @@ -1057,6 +1083,7 @@ func (s *Server) handleInitialize(ctx context.Context, params *lsproto.Initializ if s.initializeParams.Locale != nil { s.locale, _ = locale.Parse(*s.initializeParams.Locale) } + s.initLocale = s.locale if s.startWatchdog != nil && params.ProcessId.Integer != nil { s.startWatchdog(int(*params.ProcessId.Integer)) @@ -1254,7 +1281,6 @@ func (s *Server) handleInitialized(ctx context.Context, params *lsproto.Initiali TelemetryEnabled: enableTelemetry, DebounceDelay: 500 * time.Millisecond, PushDiagnosticsEnabled: !disablePushDiagnostics, - Locale: s.locale, }, FS: s.fs, Logger: s.logger, diff --git a/internal/project/client.go b/internal/project/client.go index 98ff57b6c3d..48b3f9ddd5e 100644 --- a/internal/project/client.go +++ b/internal/project/client.go @@ -4,6 +4,7 @@ import ( "context" "github.com/microsoft/typescript-go/internal/diagnostics" + "github.com/microsoft/typescript-go/internal/locale" "github.com/microsoft/typescript-go/internal/lsp/lsproto" ) @@ -18,4 +19,8 @@ type Client interface { ProgressFinish(message *diagnostics.Message, args ...any) SendTelemetry(ctx context.Context, telemetry lsproto.TelemetryEvent) error IsActive() bool + // SetLocale updates the locale used for diagnostic messages. + SetLocale(locale string) + // GetLocale returns the current display locale for diagnostic messages. + GetLocale() locale.Locale } diff --git a/internal/project/extendedconfigcache_test.go b/internal/project/extendedconfigcache_test.go index dd4a7a1493e..dec5756e7df 100644 --- a/internal/project/extendedconfigcache_test.go +++ b/internal/project/extendedconfigcache_test.go @@ -7,6 +7,7 @@ import ( "github.com/microsoft/typescript-go/internal/bundled" "github.com/microsoft/typescript-go/internal/diagnostics" + "github.com/microsoft/typescript-go/internal/locale" "github.com/microsoft/typescript-go/internal/lsp/lsproto" "github.com/microsoft/typescript-go/internal/project/logging" "github.com/microsoft/typescript-go/internal/tsoptions" @@ -44,6 +45,10 @@ func (noopClient) SendTelemetry(ctx context.Context, telemetry lsproto.Telemetry func (noopClient) IsActive() bool { return true } +func (noopClient) GetLocale() locale.Locale { return locale.Default } + +func (noopClient) SetLocale(locale string) {} + // TestExtendedConfigCacheOwnership tests the invariant that each ExtendedSourceFile // of a config in the ConfigFileRegistry is owned exactly once per snapshot that // references it, and released exactly once when that snapshot is removed. diff --git a/internal/project/session.go b/internal/project/session.go index d8776aea586..8c9ed87c5d0 100644 --- a/internal/project/session.go +++ b/internal/project/session.go @@ -62,7 +62,6 @@ type SessionOptions struct { TelemetryEnabled bool PushDiagnosticsEnabled bool DebounceDelay time.Duration - Locale locale.Locale CheckerPoolOptions CheckerPoolOptions } @@ -268,6 +267,17 @@ func (s *Session) Config() lsutil.UserPreferences { return s.workspaceUserPreferences } +func (s *Session) backgroundContext() context.Context { + return s.withCurrentLocale(s.backgroundCtx) +} + +func (s *Session) withCurrentLocale(ctx context.Context) context.Context { + if s.client == nil { + return ctx + } + return locale.WithLocale(ctx, s.client.GetLocale()) +} + // Trace implements module.ResolutionHost func (s *Session) Trace(msg string) { panic("ATA module resolution should not use tracing") @@ -280,6 +290,10 @@ func (s *Session) Configure(config lsutil.UserPreferences) { oldConfig := s.workspaceUserPreferences s.workspaceUserPreferences = config + if config.Locale != "" { + s.client.SetLocale(config.Locale) + } + // Tell the client to re-request certain commands depending on user preference changes. s.refreshInlayHintsIfNeeded(oldConfig, config) s.refreshCodeLensIfNeeded(oldConfig, config) @@ -404,7 +418,7 @@ func (s *Session) ScheduleDiagnosticsRefresh() { } // Create a new cancellable context for the debounce task - debounceCtx, cancel := context.WithCancel(s.backgroundCtx) + debounceCtx, cancel := context.WithCancel(s.backgroundContext()) s.diagnosticsRefreshGeneration++ generation := s.diagnosticsRefreshGeneration s.diagnosticsRefreshCancel = cancel @@ -433,7 +447,7 @@ func (s *Session) ScheduleDiagnosticsRefresh() { if s.options.LoggingEnabled { s.logger.Log("Running scheduled diagnostics refresh") } - if err := s.client.RefreshDiagnostics(s.backgroundCtx); err != nil && s.options.LoggingEnabled { + if err := s.client.RefreshDiagnostics(s.backgroundContext()); err != nil && s.options.LoggingEnabled { s.logger.Logf("Error refreshing diagnostics: %v", err) } }) @@ -465,7 +479,7 @@ func (s *Session) ScheduleSnapshotUpdate(reason UpdateReason) { } // Create a new cancellable context for the debounce task - debounceCtx, cancel := context.WithCancel(s.backgroundCtx) + debounceCtx, cancel := context.WithCancel(s.backgroundContext()) s.scheduledSnapshotUpdateGeneration++ generation := s.scheduledSnapshotUpdateGeneration s.scheduledSnapshotUpdateCancel = cancel @@ -553,7 +567,7 @@ func (s *Session) scheduleIdleCacheClean() { defer s.snapshotUpdateMu.Unlock() s.cancelScheduledSnapshotUpdate() - ctx := s.backgroundCtx + ctx := s.backgroundContext() fileChanges, overlays, ataChanges, newConfig := s.flushChanges(ctx) s.UpdateSnapshot(ctx, overlays, SnapshotChange{ reason: UpdateReasonIdleCleanDiskCache, @@ -584,7 +598,7 @@ func (s *Session) StartPerformanceTelemetry() { if !s.options.TelemetryEnabled { return } - ctx, cancel := context.WithCancel(s.backgroundCtx) + ctx, cancel := context.WithCancel(s.backgroundContext()) s.performanceTelemetryCancel = cancel s.backgroundQueue.Enqueue(ctx, func(ctx context.Context) { ticker := time.NewTicker(performanceTelemetryInterval) @@ -736,7 +750,7 @@ func (s *Session) sendProjectInfoTelemetryForNewProjects(oldSnapshot *Snapshot, if !s.options.TelemetryEnabled { return } - ctx := s.backgroundCtx + ctx := s.backgroundContext() collections.DiffOrderedMaps( oldSnapshot.ProjectCollection.ProjectsByPath(), newSnapshot.ProjectCollection.ProjectsByPath(), @@ -1170,7 +1184,7 @@ func (s *Session) adoptSnapshotChangeInBackground(baseSnapshot, newSnapshot *Sna // The clone's initial ref (1) is transferred to adoptSnapshotChange, // which will either promote it as the session's current snapshot or // release it if the session has moved on. - s.backgroundQueue.Enqueue(s.backgroundCtx, func(ctx context.Context) { + s.backgroundQueue.Enqueue(s.backgroundContext(), func(ctx context.Context) { s.adoptSnapshotChange(baseSnapshot, newSnapshot) }) } @@ -1245,7 +1259,7 @@ func (s *Session) updateSnapshot(ctx context.Context, overlays map[tspath.Path]* // Enqueue logging, watch updates, and diagnostic refresh tasks // !!! userPreferences/configuration updates - s.backgroundQueue.Enqueue(s.backgroundCtx, func(ctx context.Context) { + s.backgroundQueue.Enqueue(s.backgroundContext(), func(ctx context.Context) { if s.options.LoggingEnabled { s.logger.Logf("Adopted snapshot %d (parent %d) as current session snapshot (replacing %d)", newSnapshot.id, newSnapshot.parentId, oldSnapshot.id) s.logger.Log(newSnapshot.builderLogs.String()) @@ -1355,7 +1369,7 @@ func updateWatch[T any](ctx context.Context, session *Session, logger logging.Lo func (s *Session) updateWatches(oldSnapshot *Snapshot, newSnapshot *Snapshot) error { var errors []error start := time.Now() - ctx := s.backgroundCtx + ctx := s.backgroundContext() core.DiffMapsFunc( oldSnapshot.ConfigFileRegistry.configs, newSnapshot.ConfigFileRegistry.configs, @@ -1577,7 +1591,7 @@ func (s *Session) NpmInstall(cwd string, npmInstallArgs []string) ([]byte, error func (s *Session) refreshInlayHintsIfNeeded(oldPrefs lsutil.UserPreferences, newPrefs lsutil.UserPreferences) { if oldPrefs.InlayHints != newPrefs.InlayHints { - if err := s.client.RefreshInlayHints(s.backgroundCtx); err != nil && s.options.LoggingEnabled { + if err := s.client.RefreshInlayHints(s.backgroundContext()); err != nil && s.options.LoggingEnabled { s.logger.Logf("Error refreshing inlay hints: %v", err) } } @@ -1585,7 +1599,7 @@ func (s *Session) refreshInlayHintsIfNeeded(oldPrefs lsutil.UserPreferences, new func (s *Session) refreshCodeLensIfNeeded(oldPrefs lsutil.UserPreferences, newPrefs lsutil.UserPreferences) { if oldPrefs.CodeLens != newPrefs.CodeLens { - if err := s.client.RefreshCodeLens(s.backgroundCtx); err != nil && s.options.LoggingEnabled { + if err := s.client.RefreshCodeLens(s.backgroundContext()); err != nil && s.options.LoggingEnabled { s.logger.Logf("Error refreshing code lens: %v", err) } } @@ -1617,13 +1631,13 @@ func (s *Session) publishProgramDiagnostics(oldSnapshot *Snapshot, newSnapshot * } for configFilePath, oldProject := range oldSnapshot.ProjectCollection.ProjectsByPath().Entries() { if oldProject.Kind == KindConfigured && oldSnapshot.ProjectCollection.GetOpenConfiguredProjects().Has(configFilePath) { - s.publishProjectDiagnostics(s.backgroundCtx, string(configFilePath), nil, oldSnapshot.converters) + s.publishProjectDiagnostics(s.backgroundContext(), string(configFilePath), nil, oldSnapshot.converters) } } return } - ctx := s.backgroundCtx + ctx := s.backgroundContext() oldProjects := oldSnapshot.ProjectCollection.ProjectsByPath() newProjects := newSnapshot.ProjectCollection.ProjectsByPath() oldOpenProjects := oldSnapshot.ProjectCollection.GetOpenConfiguredProjects() @@ -1683,6 +1697,7 @@ func (s *Session) publishProjectDiagnostics(ctx context.Context, configFilePath if s.Config().EnableValidation.IsFalse() { diagnostics = nil } + ctx = s.withCurrentLocale(ctx) lspDiagnostics := make([]*lsproto.Diagnostic, 0, len(diagnostics)) for _, diag := range diagnostics { lspDiagnostics = append(lspDiagnostics, lsconv.DiagnosticToLSPPush(ctx, converters, diag)) @@ -1704,7 +1719,7 @@ func (s *Session) EnqueuePublishGlobalDiagnostics() { return } if s.globalDiagPublishPending.CompareAndSwap(false, true) { - s.backgroundQueue.Enqueue(s.backgroundCtx, s.publishGlobalDiagnostics) + s.backgroundQueue.Enqueue(s.backgroundContext(), s.publishGlobalDiagnostics) } } @@ -1730,7 +1745,7 @@ func (s *Session) publishGlobalDiagnostics(ctx context.Context) { func (s *Session) triggerATAForUpdatedProjects(newSnapshot *Snapshot) { for _, project := range newSnapshot.ProjectCollection.Projects() { if project.ShouldTriggerATA(newSnapshot.ID()) { - s.backgroundQueue.Enqueue(s.backgroundCtx, func(ctx context.Context) { + s.backgroundQueue.Enqueue(s.backgroundContext(), func(ctx context.Context) { var logTree *logging.LogTree if s.options.LoggingEnabled { logTree = logging.NewLogTree("Triggering ATA for project " + project.Name()) diff --git a/internal/project/session_test.go b/internal/project/session_test.go index 79208dfdf49..facd5c36312 100644 --- a/internal/project/session_test.go +++ b/internal/project/session_test.go @@ -10,6 +10,7 @@ import ( "github.com/microsoft/typescript-go/internal/bundled" "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/locale" "github.com/microsoft/typescript-go/internal/ls/lsutil" "github.com/microsoft/typescript-go/internal/lsp/lsproto" "github.com/microsoft/typescript-go/internal/project" @@ -1406,6 +1407,39 @@ export const value = content;`, assert.Equal(t, len(inlayHintsRefreshCalls), 1, "expected one RefreshInlayHints call after inlay hints preference change") }) + t.Run("sets locale when configured", func(t *testing.T) { + t.Parallel() + session, utils := projecttestutil.Setup(map[string]any{}) + prefs := lsutil.NewDefaultUserPreferences() + prefs.Locale = "fr" + + session.Configure(prefs) + + setLocaleCalls := utils.Client().SetLocaleCalls() + assert.Equal(t, len(setLocaleCalls), 1) + assert.Equal(t, setLocaleCalls[0].LocaleMoqParam, "fr") + }) + + t.Run("adds locale to background contexts", func(t *testing.T) { + t.Parallel() + session, utils := projecttestutil.Setup(map[string]any{}) + fr, ok := locale.Parse("fr") + assert.Assert(t, ok) + utils.Client().GetLocaleFunc = func() locale.Locale { + return fr + } + utils.Client().RefreshCodeLensFunc = func(ctx context.Context) error { + assert.Equal(t, locale.FromContext(ctx), fr) + return nil + } + prefs := lsutil.NewDefaultUserPreferences() + prefs.CodeLens.ReferencesCodeLensEnabled = core.TSTrue + + session.Configure(prefs) + + assert.Equal(t, len(utils.Client().RefreshCodeLensCalls()), 1) + }) + t.Run("schedules diagnostics refresh when reportStyleChecksAsWarnings changes", func(t *testing.T) { t.Parallel() files := map[string]any{ diff --git a/internal/testutil/projecttestutil/clientmock_generated.go b/internal/testutil/projecttestutil/clientmock_generated.go index 7a1b97c0365..140a3f8dcc6 100644 --- a/internal/testutil/projecttestutil/clientmock_generated.go +++ b/internal/testutil/projecttestutil/clientmock_generated.go @@ -8,6 +8,7 @@ import ( "sync" "github.com/microsoft/typescript-go/internal/diagnostics" + "github.com/microsoft/typescript-go/internal/locale" "github.com/microsoft/typescript-go/internal/lsp/lsproto" "github.com/microsoft/typescript-go/internal/project" ) @@ -22,6 +23,9 @@ var _ project.Client = &ClientMock{} // // // make and configure a mocked project.Client // mockedClient := &ClientMock{ +// GetLocaleFunc: func() locale.Locale { +// panic("mock out the GetLocale method") +// }, // IsActiveFunc: func() bool { // panic("mock out the IsActive method") // }, @@ -46,6 +50,9 @@ var _ project.Client = &ClientMock{} // SendTelemetryFunc: func(ctx context.Context, telemetry lsproto.TelemetryEvent) error { // panic("mock out the SendTelemetry method") // }, +// SetLocaleFunc: func(localeMoqParam string) { +// panic("mock out the SetLocale method") +// }, // UnwatchFilesFunc: func(ctx context.Context, id project.WatcherID) error { // panic("mock out the UnwatchFiles method") // }, @@ -59,6 +66,9 @@ var _ project.Client = &ClientMock{} // // } type ClientMock struct { + // GetLocaleFunc mocks the GetLocale method. + GetLocaleFunc func() locale.Locale + // IsActiveFunc mocks the IsActive method. IsActiveFunc func() bool @@ -83,6 +93,9 @@ type ClientMock struct { // SendTelemetryFunc mocks the SendTelemetry method. SendTelemetryFunc func(ctx context.Context, telemetry lsproto.TelemetryEvent) error + // SetLocaleFunc mocks the SetLocale method. + SetLocaleFunc func(localeMoqParam string) + // UnwatchFilesFunc mocks the UnwatchFiles method. UnwatchFilesFunc func(ctx context.Context, id project.WatcherID) error @@ -91,6 +104,8 @@ type ClientMock struct { // calls tracks calls to the methods. calls struct { + // GetLocale holds details about calls to the GetLocale method. + GetLocale []struct{} // IsActive holds details about calls to the IsActive method. IsActive []struct{} // ProgressFinish holds details about calls to the ProgressFinish method. @@ -136,6 +151,11 @@ type ClientMock struct { // Telemetry is the telemetry argument value. Telemetry lsproto.TelemetryEvent } + // SetLocale holds details about calls to the SetLocale method. + SetLocale []struct { + // LocaleMoqParam is the localeMoqParam argument value. + LocaleMoqParam string + } // UnwatchFiles holds details about calls to the UnwatchFiles method. UnwatchFiles []struct { // Ctx is the ctx argument value. @@ -153,6 +173,7 @@ type ClientMock struct { Watchers []*lsproto.FileSystemWatcher } } + lockGetLocale sync.RWMutex lockIsActive sync.RWMutex lockProgressFinish sync.RWMutex lockProgressStart sync.RWMutex @@ -161,10 +182,36 @@ type ClientMock struct { lockRefreshDiagnostics sync.RWMutex lockRefreshInlayHints sync.RWMutex lockSendTelemetry sync.RWMutex + lockSetLocale sync.RWMutex lockUnwatchFiles sync.RWMutex lockWatchFiles sync.RWMutex } +// GetLocale calls GetLocaleFunc. +func (mock *ClientMock) GetLocale() locale.Locale { + callInfo := struct{}{} + mock.lockGetLocale.Lock() + mock.calls.GetLocale = append(mock.calls.GetLocale, callInfo) + mock.lockGetLocale.Unlock() + if mock.GetLocaleFunc == nil { + var localeOut locale.Locale + return localeOut + } + return mock.GetLocaleFunc() +} + +// GetLocaleCalls gets all the calls that were made to GetLocale. +// Check the length with: +// +// len(mockedClient.GetLocaleCalls()) +func (mock *ClientMock) GetLocaleCalls() []struct{} { + var calls []struct{} + mock.lockGetLocale.RLock() + calls = mock.calls.GetLocale + mock.lockGetLocale.RUnlock() + return calls +} + // IsActive calls IsActiveFunc. func (mock *ClientMock) IsActive() bool { callInfo := struct{}{} @@ -435,6 +482,38 @@ func (mock *ClientMock) SendTelemetryCalls() []struct { return calls } +// SetLocale calls SetLocaleFunc. +func (mock *ClientMock) SetLocale(localeMoqParam string) { + callInfo := struct { + LocaleMoqParam string + }{ + LocaleMoqParam: localeMoqParam, + } + mock.lockSetLocale.Lock() + mock.calls.SetLocale = append(mock.calls.SetLocale, callInfo) + mock.lockSetLocale.Unlock() + if mock.SetLocaleFunc == nil { + return + } + mock.SetLocaleFunc(localeMoqParam) +} + +// SetLocaleCalls gets all the calls that were made to SetLocale. +// Check the length with: +// +// len(mockedClient.SetLocaleCalls()) +func (mock *ClientMock) SetLocaleCalls() []struct { + LocaleMoqParam string +} { + var calls []struct { + LocaleMoqParam string + } + mock.lockSetLocale.RLock() + calls = mock.calls.SetLocale + mock.lockSetLocale.RUnlock() + return calls +} + // UnwatchFiles calls UnwatchFilesFunc. func (mock *ClientMock) UnwatchFiles(ctx context.Context, id project.WatcherID) error { callInfo := struct {