Skip to content

Commit a7acc87

Browse files
authored
Update snapshot and projects on file close (#3837)
1 parent 54617df commit a7acc87

6 files changed

Lines changed: 241 additions & 8 deletions

File tree

internal/project/api.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
func (s *Session) APIOpenProject(ctx context.Context, configFileName string, apiFileChanges FileChangeSummary) (*Project, *Snapshot, error) {
1212
s.snapshotUpdateMu.Lock()
1313
defer s.snapshotUpdateMu.Unlock()
14+
s.cancelScheduledSnapshotUpdate()
1415

1516
fileChanges, overlays, ataChanges, _ := s.flushChanges(ctx)
1617
mergeFileChangeSummary(&fileChanges, apiFileChanges)
@@ -39,6 +40,7 @@ func (s *Session) APIOpenProject(ctx context.Context, configFileName string, api
3940
func (s *Session) APIUpdateWithFileChanges(ctx context.Context, apiFileChanges FileChangeSummary) *Snapshot {
4041
s.snapshotUpdateMu.Lock()
4142
defer s.snapshotUpdateMu.Unlock()
43+
s.cancelScheduledSnapshotUpdate()
4244

4345
fileChanges, overlays, ataChanges, _ := s.flushChanges(ctx)
4446
mergeFileChangeSummary(&fileChanges, apiFileChanges)

internal/project/project_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,23 @@ import (
1414
"gotest.tools/v3/assert"
1515
)
1616

17+
type publishDiagnosticsCall = struct {
18+
Ctx context.Context
19+
Params *lsproto.PublishDiagnosticsParams
20+
}
21+
22+
// filterDiagnosticsByURI returns all PublishDiagnostics calls matching the given URI,
23+
// starting from the given index.
24+
func filterDiagnosticsByURI(calls []publishDiagnosticsCall, uri lsproto.DocumentUri, from int) []publishDiagnosticsCall {
25+
var result []publishDiagnosticsCall
26+
for i := from; i < len(calls); i++ {
27+
if calls[i].Params.Uri == uri {
28+
result = append(result, calls[i])
29+
}
30+
}
31+
return result
32+
}
33+
1734
// These tests explicitly verify ProgramUpdateKind using subtests with shared helpers.
1835
func TestProjectProgramUpdateKind(t *testing.T) {
1936
t.Parallel()
@@ -359,6 +376,51 @@ func TestPushDiagnostics(t *testing.T) {
359376
}
360377
assert.Assert(t, hasGlobalDiag, "expected a 'Cannot find global' diagnostic on tsconfig.json, got: %v", lastTsconfigCall.Params.Diagnostics)
361378
})
379+
380+
t.Run("cleans tsconfig diagnostics after TS files close and restores them after TS file is reopened", func(t *testing.T) {
381+
t.Parallel()
382+
files := map[string]any{
383+
"/src/tsconfig.json": `{"compilerOptions": {"baseUrl": "."}}`,
384+
"/src/index.ts": "export const x = 1;",
385+
}
386+
session, utils := projecttestutil.Setup(files)
387+
uri := lsproto.DocumentUri("file:///src/index.ts")
388+
session.DidOpenFile(context.Background(), uri, 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
389+
_, err := session.GetLanguageService(context.Background(), uri)
390+
assert.NilError(t, err)
391+
session.WaitForBackgroundTasks()
392+
393+
calls := utils.Client().PublishDiagnosticsCalls()
394+
tsconfigCalls := filterDiagnosticsByURI(calls, "file:///src/tsconfig.json", 0)
395+
assert.Assert(t, len(tsconfigCalls) > 0, "expected PublishDiagnostics call for tsconfig.json after opening file")
396+
assert.Equal(t, len(tsconfigCalls[0].Params.Diagnostics), 1, "expected one diagnostic on tsconfig.json after opening file")
397+
398+
callsBeforeClose := len(calls)
399+
400+
session.DidCloseFile(context.Background(), uri)
401+
session.WaitForBackgroundTasks()
402+
403+
// Cleans up diagnostics after close
404+
calls = utils.Client().PublishDiagnosticsCalls()
405+
clearCalls := filterDiagnosticsByURI(calls, "file:///src/tsconfig.json", callsBeforeClose)
406+
assert.Assert(t, len(clearCalls) > 0, "expected PublishDiagnostics call for tsconfig.json after project close")
407+
lastClearCall := clearCalls[len(clearCalls)-1]
408+
assert.Equal(t, len(lastClearCall.Params.Diagnostics), 0, "expected empty diagnostics after project close")
409+
410+
callsBeforeReopen := len(calls)
411+
412+
session.DidOpenFile(context.Background(), uri, 2, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
413+
_, err = session.GetLanguageService(context.Background(), uri)
414+
assert.NilError(t, err)
415+
session.WaitForBackgroundTasks()
416+
417+
// Restores diagnostics after reopen
418+
calls = utils.Client().PublishDiagnosticsCalls()
419+
reopenedCalls := filterDiagnosticsByURI(calls, "file:///src/tsconfig.json", callsBeforeReopen)
420+
assert.Assert(t, len(reopenedCalls) > 0, "expected PublishDiagnostics call for tsconfig.json after reopening file")
421+
lastReopenedCall := reopenedCalls[len(reopenedCalls)-1]
422+
assert.Equal(t, len(lastReopenedCall.Params.Diagnostics), 1, "expected one diagnostic on tsconfig.json after reopening file")
423+
})
362424
}
363425

364426
func TestDisplayName(t *testing.T) {

internal/project/projectcollection.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package project
33
import (
44
"cmp"
55
"slices"
6+
"sync"
67

78
"github.com/microsoft/typescript-go/internal/collections"
89
"github.com/microsoft/typescript-go/internal/core"
@@ -22,12 +23,18 @@ type ProjectCollection struct {
2223
// configuredProjects is the set of loaded projects associated with a tsconfig
2324
// file, keyed by the config file path.
2425
configuredProjects map[tspath.Path]*Project
26+
// openFiles is the set of open file paths associated with the snapshot that owns
27+
// this project collection.
28+
openFiles collections.Set[tspath.Path]
2529
// inferredProject is a fallback project that is used when no configured
2630
// project can be found for an open file.
2731
inferredProject *Project
2832
// apiOpenedProjects is the set of projects that should be kept open for
2933
// API clients.
3034
apiOpenedProjects map[tspath.Path]struct{}
35+
36+
openConfiguredProjectsOnce sync.Once
37+
openConfiguredProjects *collections.Set[tspath.Path]
3138
}
3239

3340
func (c *ProjectCollection) ConfigFileRegistry() *ConfigFileRegistry { return c.configFileRegistry }
@@ -107,6 +114,37 @@ func (c *ProjectCollection) GetProjectsContainingFile(path tspath.Path) []ls.Pro
107114
return projects
108115
}
109116

117+
// GetOpenConfiguredProjects returns configured projects containing at least one open file.
118+
func (c *ProjectCollection) GetOpenConfiguredProjects() *collections.Set[tspath.Path] {
119+
c.openConfiguredProjectsOnce.Do(func() {
120+
openProjects := collections.NewSetWithSizeHint[tspath.Path](len(c.configuredProjects))
121+
for path := range c.openFiles.Keys() {
122+
if projectPath, ok := c.fileDefaultProjects[path]; ok && projectPath != inferredProjectName {
123+
if _, ok := c.configuredProjects[projectPath]; ok {
124+
openProjects.Add(projectPath)
125+
continue
126+
}
127+
}
128+
129+
for _, project := range c.configuredProjects {
130+
if project.containsFile(path) {
131+
openProjects.Add(project.configFilePath)
132+
}
133+
}
134+
}
135+
c.openConfiguredProjects = openProjects
136+
})
137+
return c.openConfiguredProjects
138+
}
139+
140+
func openFilePaths(overlays map[tspath.Path]*Overlay) collections.Set[tspath.Path] {
141+
openFiles := collections.Set[tspath.Path]{M: make(map[tspath.Path]struct{}, len(overlays))}
142+
for path := range overlays {
143+
openFiles.Add(path)
144+
}
145+
return openFiles
146+
}
147+
110148
// !!! result could be cached
111149
func (c *ProjectCollection) GetDefaultProject(path tspath.Path) *Project {
112150
if result, ok := c.fileDefaultProjects[path]; ok {
@@ -230,6 +268,7 @@ func (c *ProjectCollection) clone() *ProjectCollection {
230268
toPath: c.toPath,
231269
configFileRegistry: c.configFileRegistry,
232270
configuredProjects: c.configuredProjects,
271+
openFiles: c.openFiles,
233272
inferredProject: c.inferredProject,
234273
fileDefaultProjects: c.fileDefaultProjects,
235274
apiOpenedProjects: c.apiOpenedProjects,

internal/project/projectcollectionbuilder.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ type ProjectCollectionBuilder struct {
4343
newSnapshotID uint64
4444
programStructureChanged bool
4545
defaultProjectsInvalidated bool
46+
openFilesChanged bool
4647

4748
fileDefaultProjects map[tspath.Path]tspath.Path
4849
configuredProjects *dirty.SyncMap[tspath.Path, *Project]
@@ -98,6 +99,11 @@ func (b *ProjectCollectionBuilder) Finalize(logger *logging.LogTree) (*ProjectCo
9899
newProjectCollection.configuredProjects = configuredProjects
99100
}
100101

102+
if b.openFilesChanged {
103+
ensureCloned()
104+
newProjectCollection.openFiles = openFilePaths(b.fs.overlays)
105+
}
106+
101107
if !maps.Equal(b.fileDefaultProjects, b.base.fileDefaultProjects) {
102108
ensureCloned()
103109
newProjectCollection.fileDefaultProjects = b.fileDefaultProjects
@@ -183,6 +189,8 @@ func (b *ProjectCollectionBuilder) HandleAPIRequest(apiRequest *APISnapshotReque
183189
}
184190

185191
func (b *ProjectCollectionBuilder) DidChangeFiles(summary FileChangeSummary, logger *logging.LogTree) {
192+
b.openFilesChanged = b.openFilesChanged || summary.Opened != "" || summary.Closed.Len() > 0
193+
186194
changedFiles := make([]tspath.Path, 0, summary.Changed.Len())
187195
for uri := range summary.Changed.Keys() {
188196
fileName := uri.FileName()

0 commit comments

Comments
 (0)