From d384b122cbbc768f9eda7ead28e9b43ec6aa4307 Mon Sep 17 00:00:00 2001 From: uditDewan Date: Sat, 25 Jul 2026 00:35:40 -0400 Subject: [PATCH] Refresh config file diagnostics when a config file is saved Diagnostics for tsconfig/jsconfig files are only published as part of a snapshot update. Saving an edited config file left the change pending until some unrelated request happened to flush it, so the config file's diagnostics went stale. Schedule a snapshot update when a saved file is a config file. Fixes #4713 --- internal/project/project_test.go | 34 ++++++++++++++++++++++++++++++++ internal/project/session.go | 24 +++++++++++++++++++++- internal/project/snapshot.go | 2 ++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/internal/project/project_test.go b/internal/project/project_test.go index 2413dbc49e4..e5ef3ddb14d 100644 --- a/internal/project/project_test.go +++ b/internal/project/project_test.go @@ -411,6 +411,40 @@ func TestPushDiagnostics(t *testing.T) { assert.Equal(t, len(lastTsconfigCall.Params.Diagnostics), 0, "expected no diagnostics after removing baseUrl option") }) + t.Run("updates diagnostics when an open config file is saved", func(t *testing.T) { + t.Parallel() + files := map[string]any{ + "/src/tsconfig.json": `{"compilerOptions": {"target": "es2020"}}`, + "/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() + + // Edit the config file in the editor and save it. No other request follows, so the + // save is what has to get the new diagnostics published. + newContent := `{"compilerOptions": {"target": "es5"}}` + session.DidOpenFile(context.Background(), "file:///src/tsconfig.json", 1, files["/src/tsconfig.json"].(string), lsproto.LanguageKindJSON) + session.DidChangeFile(context.Background(), "file:///src/tsconfig.json", 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{ + {WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: newContent}}, + }) + assert.NilError(t, utils.FS().WriteFile("/src/tsconfig.json", newContent)) + session.DidSaveFile(context.Background(), "file:///src/tsconfig.json") + session.WaitForBackgroundTasks() + + calls := utils.Client().PublishDiagnosticsCalls() + tsconfigCalls := filterDiagnosticsByURI(calls, "file:///src/tsconfig.json", 0) + assert.Assert(t, len(tsconfigCalls) > 0, "expected PublishDiagnostics call for tsconfig.json") + lastTsconfigCall := tsconfigCalls[len(tsconfigCalls)-1] + + expectedMessage := "Option 'target=ES5' has been removed." + assert.Assert(t, slices.ContainsFunc(lastTsconfigCall.Params.Diagnostics, func(diag *lsproto.Diagnostic) bool { + return strings.Contains(diag.Message.AsString(), expectedMessage) + }), "expected removed target diagnostic on tsconfig.json, got: %v", lastTsconfigCall.Params.Diagnostics) + }) + t.Run("does not publish for inferred projects", func(t *testing.T) { t.Parallel() files := map[string]any{ diff --git a/internal/project/session.go b/internal/project/session.go index 8c9ed87c5d0..d5f0ca65256 100644 --- a/internal/project/session.go +++ b/internal/project/session.go @@ -44,6 +44,7 @@ const ( UpdateReasonRequestedLoadProjectTree UpdateReasonRequestedLanguageServiceWithAutoImports UpdateReasonIdleCleanDiskCache + UpdateReasonDidSaveConfigFile ) // watchRequestTimeout is the maximum time to wait for the client to respond to @@ -360,11 +361,32 @@ func (s *Session) DidChangeFile(ctx context.Context, uri lsproto.DocumentUri, ve func (s *Session) DidSaveFile(ctx context.Context, uri lsproto.DocumentUri) { s.scheduleIdleCacheClean() s.pendingFileChangesMu.Lock() - defer s.pendingFileChangesMu.Unlock() s.pendingFileChanges = append(s.pendingFileChanges, FileChange{ Kind: FileChangeKindSave, URI: uri, }) + s.pendingFileChangesMu.Unlock() + + if s.isConfigFile(uri) { + // Diagnostics for config files are only pushed as part of a snapshot update. + // Editing a config file usually isn't followed by any request that would flush + // the pending changes, so schedule an update to get its diagnostics republished. + s.ScheduleSnapshotUpdate(UpdateReasonDidSaveConfigFile) + } +} + +// isConfigFile returns true if the given file is a config file known to the current +// snapshot, or is named like one. +func (s *Session) isConfigFile(uri lsproto.DocumentUri) bool { + fileName := uri.FileName() + baseName := tspath.GetBaseFileName(fileName) + snapshot := s.Snapshot() + if baseName == "tsconfig.json" || baseName == "jsconfig.json" || + (snapshot.ConfigFileRegistry.customConfigFileName != "" && baseName == snapshot.ConfigFileRegistry.customConfigFileName) { + return true + } + // Configs pulled in by `extends` or project references can be named anything. + return snapshot.ConfigFileRegistry.GetConfig(snapshot.toPath(fileName)) != nil } func (s *Session) DidChangeWatchedFiles(ctx context.Context, changes []*lsproto.FileEvent) { diff --git a/internal/project/snapshot.go b/internal/project/snapshot.go index b49e5405dd8..6dbd847fdbb 100644 --- a/internal/project/snapshot.go +++ b/internal/project/snapshot.go @@ -268,6 +268,8 @@ 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 UpdateReasonDidSaveConfigFile: + logger.Logf("Reason: DidSaveConfigFile - %v", change.fileChanges.Changed) case UpdateReasonDidChangeCompilerOptionsForInferredProjects: logger.Logf("Reason: DidChangeCompilerOptionsForInferredProjects") case UpdateReasonRequestedLanguageServicePendingChanges: