diff --git a/internal/project/ata.go b/internal/project/ata.go index fcea2b782c..d27f8f9711 100644 --- a/internal/project/ata.go +++ b/internal/project/ata.go @@ -145,7 +145,7 @@ func (ti *TypingsInstaller) EnqueueInstallTypingsRequest(p *Project, typingsInfo } func (ti *TypingsInstaller) discoverAndInstallTypings(p *Project, typingsInfo *TypingsInfo, fileNames []string, projectRootPath string) { - ti.init((p)) + ti.init(p) cachedTypingPaths, newTypingNames, filesToWatch := DiscoverTypings( p.FS(), diff --git a/internal/project/project.go b/internal/project/project.go index 2d041dda3e..dfb34b3f2e 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -171,6 +171,7 @@ func NewConfiguredProject(configFileName string, configFilePath tspath.Path, hos project.configFileName = configFileName project.configFilePath = configFilePath project.initialLoadPending = true + project.pendingReload = PendingReloadFull client := host.Client() if host.IsWatchEnabled() && client != nil { project.rootFilesWatch = newWatchedFiles(project, lsproto.WatchKindChange|lsproto.WatchKindCreate|lsproto.WatchKindDelete, core.Identity, "root files") @@ -193,6 +194,7 @@ func NewProject(name string, kind Kind, currentDirectory string, host ProjectHos kind: kind, currentDirectory: currentDirectory, rootFileNames: &collections.OrderedMap[tspath.Path, string]{}, + dirty: true, } project.comparePathsOptions = tspath.ComparePathsOptions{ CurrentDirectory: currentDirectory, @@ -252,8 +254,8 @@ func (p *Project) GetSourceFile(fileName string, path tspath.Path, languageVersi // Updates the program if needed. func (p *Project) GetProgram() *compiler.Program { - p.updateGraph() - return p.program + program, _ := p.updateGraph() + return program } // NewLine implements compiler.CompilerHost. @@ -292,6 +294,9 @@ func (p *Project) GetLanguageServiceForRequest(ctx context.Context) (*ls.Languag panic("context must already have a request ID") } program := p.GetProgram() + if program == nil { + panic("must have gced by other request") + } checkerPool := p.checkerPool snapshot := &snapshot{ project: p, @@ -362,6 +367,16 @@ func (p *Project) updateWatchers(ctx context.Context) { p.affectingLocationsWatch.update(ctx, affectingLocationGlobs) } +func (p *Project) tryInvokeWildCardDirectories(fileName string, path tspath.Path) bool { + if p.kind == KindConfigured { + if p.rootFileNames.Has(path) || p.parsedCommandLine.MatchesFileName(fileName) { + p.SetPendingReload(PendingReloadFileNames) + return true + } + } + return false +} + // onWatchEventForNilScriptInfo is fired for watch events that are not the // project tsconfig, and do not have a ScriptInfo for the associated file. // This could be a case of one of the following: @@ -371,14 +386,9 @@ func (p *Project) updateWatchers(ctx context.Context) { // part of the project, e.g., a .js file in a project without --allowJs. func (p *Project) onWatchEventForNilScriptInfo(fileName string) { path := p.toPath(fileName) - if p.kind == KindConfigured { - if p.rootFileNames.Has(path) || p.parsedCommandLine.MatchesFileName(fileName) { - p.pendingReload = PendingReloadFileNames - p.markAsDirty() - return - } + if p.tryInvokeWildCardDirectories(fileName, path) { + return } - if _, ok := p.failedLookupsWatch.data[path]; ok { p.markAsDirty() } else if _, ok := p.affectingLocationsWatch.data[path]; ok { @@ -430,6 +440,15 @@ func (p *Project) MarkFileAsDirty(path tspath.Path) { } } +func (p *Project) SetPendingReload(level PendingReload) { + p.mu.Lock() + defer p.mu.Unlock() + if level > p.pendingReload { + p.pendingReload = level + p.markAsDirtyLocked() + } +} + func (p *Project) markAsDirty() { p.mu.Lock() defer p.mu.Unlock() @@ -453,17 +472,16 @@ func (p *Project) onFileAddedOrRemoved() { // Returns true if the set of files in has changed. NOTE: this is the // opposite of the return value in Strada, which was frequently inverted, // as in `updateProjectIfDirty()`. -func (p *Project) updateGraph() bool { +func (p *Project) updateGraph() (*compiler.Program, bool) { p.mu.Lock() defer p.mu.Unlock() - if !p.dirty { - return false + if !p.dirty || p.isClosed() { + return p.program, false } start := time.Now() p.Log("Starting updateGraph: Project: " + p.name) - var writeFileNames bool oldProgram := p.program p.initialLoadPending = false @@ -471,14 +489,15 @@ func (p *Project) updateGraph() bool { switch p.pendingReload { case PendingReloadFileNames: p.parsedCommandLine = tsoptions.ReloadFileNamesOfParsedCommandLine(p.parsedCommandLine, p.host.FS()) - writeFileNames = p.setRootFiles(p.parsedCommandLine.FileNames()) + p.setRootFiles(p.parsedCommandLine.FileNames()) p.programConfig = nil + p.pendingReload = PendingReloadNone case PendingReloadFull: - if err := p.loadConfig(); err != nil { + err := p.LoadConfig() + if err != nil { panic(fmt.Sprintf("failed to reload config: %v", err)) } } - p.pendingReload = PendingReloadNone } oldProgramReused := p.updateProgram() @@ -486,7 +505,7 @@ func (p *Project) updateGraph() bool { p.hasAddedorRemovedFiles.Store(false) p.dirty = false p.dirtyFilePath = "" - if writeFileNames { + if hasAddedOrRemovedFiles { p.Log(p.print(true /*writeFileNames*/, true /*writeFileExplanation*/, false /*writeFileVersionAndText*/, &strings.Builder{})) } else if p.program != oldProgram { p.Log("Different program with same set of root files") @@ -496,6 +515,7 @@ func (p *Project) updateGraph() bool { for _, oldSourceFile := range oldProgram.GetSourceFiles() { if p.program.GetSourceFileByPath(oldSourceFile.Path()) == nil { p.host.DocumentRegistry().ReleaseDocument(oldSourceFile, oldProgram.Options()) + p.detachScriptInfoIfNotInferredRoot(oldSourceFile.Path()) } } } @@ -505,7 +525,7 @@ func (p *Project) updateGraph() bool { p.updateWatchers(context.TODO()) } p.Logf("Finishing updateGraph: Project: %s version: %d in %s", p.name, p.version, time.Since(start)) - return true + return p.program, true } func (p *Project) updateProgram() bool { @@ -707,7 +727,7 @@ func (p *Project) extractUnresolvedImportsFromSourceFile(file *ast.SourceFile, o func (p *Project) UpdateTypingFiles(typingsInfo *TypingsInfo, typingFiles []string) { p.mu.Lock() defer p.mu.Unlock() - if p.typingsInfo != typingsInfo { + if p.isClosed() || p.typingsInfo != typingsInfo { return } @@ -737,6 +757,12 @@ func (p *Project) UpdateTypingFiles(typingsInfo *TypingsInfo, typingFiles []stri } func (p *Project) WatchTypingLocations(files []string) { + p.mu.Lock() + defer p.mu.Unlock() + if p.isClosed() { + return + } + client := p.host.Client() if !p.host.IsWatchEnabled() || client == nil { return @@ -804,23 +830,13 @@ func (p *Project) isRoot(info *ScriptInfo) bool { return p.rootFileNames.Has(info.path) } -func (p *Project) RemoveFile(info *ScriptInfo, fileExists bool, detachFromProject bool) { +func (p *Project) RemoveFile(info *ScriptInfo, fileExists bool) { p.mu.Lock() defer p.mu.Unlock() - p.removeFile(info, fileExists, detachFromProject) - p.markAsDirtyLocked() -} - -func (p *Project) removeFile(info *ScriptInfo, fileExists bool, detachFromProject bool) { - if p.isRoot(info) { - switch p.kind { - case KindInferred: - p.rootFileNames.Delete(info.path) - p.typeAcquisition = nil - p.programConfig = nil - case KindConfigured: - p.pendingReload = PendingReloadFileNames - } + if p.isRoot(info) && p.kind == KindInferred { + p.rootFileNames.Delete(info.path) + p.typeAcquisition = nil + p.programConfig = nil } p.onFileAddedOrRemoved() @@ -832,49 +848,34 @@ func (p *Project) removeFile(info *ScriptInfo, fileExists bool, detachFromProjec // this.resolutionCache.invalidateResolutionOfFile(info.path); // } // this.cachedUnresolvedImportsPerFile.delete(info.path); - if detachFromProject { - info.detachFromProject(p) - } + p.markAsDirtyLocked() } -func (p *Project) AddRoot(info *ScriptInfo) { +func (p *Project) AddInferredProjectRoot(info *ScriptInfo) { p.mu.Lock() defer p.mu.Unlock() - p.addRoot(info) + if p.isRoot(info) { + panic("script info is already a root") + } + p.rootFileNames.Set(info.path, info.fileName) p.programConfig = nil - p.markAsDirtyLocked() -} - -func (p *Project) addRoot(info *ScriptInfo) { + p.typeAcquisition = nil // !!! // if p.kind == KindInferred { // p.host.startWatchingConfigFilesForInferredProjectRoot(info.path); // // handle JS toggling // } - if p.isRoot(info) { - panic("script info is already a root") - } - p.rootFileNames.Set(info.path, info.fileName) - if p.kind == KindInferred { - p.typeAcquisition = nil - } info.attachToProject(p) + p.markAsDirtyLocked() } func (p *Project) LoadConfig() error { - if err := p.loadConfig(); err != nil { - return err - } - p.markAsDirty() - return nil -} - -func (p *Project) loadConfig() error { if p.kind != KindConfigured { panic("loadConfig called on non-configured project") } p.programConfig = nil + p.pendingReload = PendingReloadNone if configFileContent, ok := p.host.FS().ReadFile(p.configFileName); ok { configDir := tspath.GetDirectoryPath(p.configFileName) tsConfigSourceFile := tsoptions.NewTsconfigSourceFileFromFilePath(p.configFileName, p.configFilePath, configFileContent) @@ -911,43 +912,28 @@ func (p *Project) loadConfig() error { } // setRootFiles returns true if the set of root files has changed. -func (p *Project) setRootFiles(rootFileNames []string) bool { - var hasChanged bool +func (p *Project) setRootFiles(rootFileNames []string) { newRootScriptInfos := make(map[tspath.Path]struct{}, len(rootFileNames)) for _, file := range rootFileNames { - scriptKind := p.getScriptKind(file) path := p.toPath(file) // !!! updateNonInferredProjectFiles uses a fileExists check, which I guess // could be needed if a watcher fails? - scriptInfo := p.host.GetOrCreateScriptInfoForFile(file, path, scriptKind) newRootScriptInfos[path] = struct{}{} - isAlreadyRoot := p.rootFileNames.Has(path) - hasChanged = hasChanged || !isAlreadyRoot - - if !isAlreadyRoot && scriptInfo != nil { - p.addRoot(scriptInfo) - if scriptInfo.isOpen { - // !!! - // s.removeRootOfInferredProjectIfNowPartOfOtherProject(scriptInfo) - } - } else if !isAlreadyRoot { - p.rootFileNames.Set(path, file) - } + p.rootFileNames.Set(path, file) + // if !isAlreadyRoot { + // if scriptInfo.isOpen { + // !!!s.removeRootOfInferredProjectIfNowPartOfOtherProject(scriptInfo) + // } + // } } if p.rootFileNames.Size() > len(rootFileNames) { - hasChanged = true for root := range p.rootFileNames.Keys() { if _, ok := newRootScriptInfos[root]; !ok { - if info := p.host.GetScriptInfoByPath(root); info != nil { - p.removeFile(info, true /*fileExists*/, true /*detachFromProject*/) - } else { - p.rootFileNames.Delete(root) - } + p.rootFileNames.Delete(root) } } } - return hasChanged } func (p *Project) clearSourceMapperCache() { @@ -994,21 +980,20 @@ func (p *Project) GetFileNames(excludeFilesFromExternalLibraries bool, excludeCo } func (p *Project) print(writeFileNames bool, writeFileExplanation bool, writeFileVersionAndText bool, builder *strings.Builder) string { - builder.WriteString(fmt.Sprintf("Project '%s' (%s)\n", p.name, p.kind.String())) + builder.WriteString(fmt.Sprintf("\nProject '%s' (%s)\n", p.name, p.kind.String())) if p.initialLoadPending { - builder.WriteString("\tFiles (0) InitialLoadPending\n") + builder.WriteString("\n\tFiles (0) InitialLoadPending\n") } else if p.program == nil { - builder.WriteString("\tFiles (0) NoProgram\n") + builder.WriteString("\n\tFiles (0) NoProgram\n") } else { sourceFiles := p.program.GetSourceFiles() - builder.WriteString(fmt.Sprintf("\tFiles (%d)\n", len(sourceFiles))) + builder.WriteString(fmt.Sprintf("\n\tFiles (%d)\n", len(sourceFiles))) if writeFileNames { for _, sourceFile := range sourceFiles { - builder.WriteString("\t\t" + sourceFile.FileName()) + builder.WriteString("\n\t\t" + sourceFile.FileName()) if writeFileVersionAndText { builder.WriteString(fmt.Sprintf(" %d %s", sourceFile.Version, sourceFile.Text())) } - builder.WriteRune('\n') } // !!! // if writeFileExplanation {} @@ -1026,8 +1011,62 @@ func (p *Project) Logf(format string, args ...interface{}) { p.Log(fmt.Sprintf(format, args...)) } +func (p *Project) detachScriptInfoIfNotInferredRoot(path tspath.Path) { + // We might not find the script info in case its not associated with the project any more + // and project graph was not updated (eg delayed update graph in case of files changed/deleted on the disk) + if scriptInfo := p.host.GetScriptInfoByPath(path); scriptInfo != nil && + (p.kind != KindInferred || !p.isRoot(scriptInfo)) { + scriptInfo.detachFromProject(p) + } +} + func (p *Project) Close() { - // !!! + p.mu.Lock() + defer p.mu.Unlock() + + if p.program != nil { + for _, sourceFile := range p.program.GetSourceFiles() { + p.host.DocumentRegistry().ReleaseDocument(sourceFile, p.program.Options()) + // Detach script info if its not root or is root of non inferred project + p.detachScriptInfoIfNotInferredRoot(sourceFile.Path()) + } + p.program = nil + } + + if p.kind == KindInferred { + // Release root script infos for inferred projects. + for path := range p.rootFileNames.Keys() { + if info := p.host.GetScriptInfoByPath(path); info != nil { + info.detachFromProject(p) + } + } + } + p.rootFileNames = nil + p.parsedCommandLine = nil + p.programConfig = nil + p.checkerPool = nil + p.unresolvedImportsPerFile = nil + p.unresolvedImports = nil + p.typingsInfo = nil + p.typingFiles = nil + + // Clean up file watchers waiting for missing files + client := p.host.Client() + if p.host.IsWatchEnabled() && client != nil { + ctx := context.Background() + if p.rootFilesWatch != nil { + p.rootFilesWatch.update(ctx, nil) + } + + p.failedLookupsWatch.update(ctx, nil) + p.affectingLocationsWatch.update(ctx, nil) + p.typingsFilesWatch.update(ctx, nil) + p.typingsDirectoryWatch.update(ctx, nil) + } +} + +func (p *Project) isClosed() bool { + return p.rootFileNames == nil } func formatFileList(files []string, linePrefix string, groupSuffix string) string { diff --git a/internal/project/projectlifetime_test.go b/internal/project/projectlifetime_test.go new file mode 100644 index 0000000000..60994c9170 --- /dev/null +++ b/internal/project/projectlifetime_test.go @@ -0,0 +1,168 @@ +package project_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/bundled" + "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/testutil/projecttestutil" + "github.com/microsoft/typescript-go/internal/tspath" + "gotest.tools/v3/assert" +) + +func TestProjectLifetime(t *testing.T) { + t.Parallel() + if !bundled.Embedded { + t.Skip("bundled files are not embedded") + } + t.Run("configured project", func(t *testing.T) { + t.Parallel() + files := map[string]any{ + "/home/projects/TS/p1/tsconfig.json": `{ + "compilerOptions": { + "noLib": true, + "module": "nodenext", + "strict": true + }, + "include": ["src"] + }`, + "/home/projects/TS/p1/src/index.ts": `import { x } from "./x";`, + "/home/projects/TS/p1/src/x.ts": `export const x = 1;`, + "/home/projects/TS/p1/config.ts": `let x = 1, y = 2;`, + "/home/projects/TS/p2/tsconfig.json": `{ + "compilerOptions": { + "noLib": true, + "module": "nodenext", + "strict": true + }, + "include": ["src"] + }`, + "/home/projects/TS/p2/src/index.ts": `import { x } from "./x";`, + "/home/projects/TS/p2/src/x.ts": `export const x = 1;`, + "/home/projects/TS/p2/config.ts": `let x = 1, y = 2;`, + "/home/projects/TS/p3/tsconfig.json": `{ + "compilerOptions": { + "noLib": true, + "module": "nodenext", + "strict": true + }, + "include": ["src"] + }`, + "/home/projects/TS/p3/src/index.ts": `import { x } from "./x";`, + "/home/projects/TS/p3/src/x.ts": `export const x = 1;`, + "/home/projects/TS/p3/config.ts": `let x = 1, y = 2;`, + } + service, host := projecttestutil.Setup(files, nil) + assert.Equal(t, len(service.Projects()), 0) + service.OpenFile("/home/projects/TS/p1/src/index.ts", files["/home/projects/TS/p1/src/index.ts"].(string), core.ScriptKindTS, "") + service.OpenFile("/home/projects/TS/p2/src/index.ts", files["/home/projects/TS/p2/src/index.ts"].(string), core.ScriptKindTS, "") + assert.Equal(t, len(service.Projects()), 2) + assert.Assert(t, service.ConfiguredProject(tspath.ToPath("/home/projects/TS/p1/tsconfig.json", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) != nil) + assert.Assert(t, service.ConfiguredProject(tspath.ToPath("/home/projects/TS/p2/tsconfig.json", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) != nil) + assert.Equal(t, len(host.ClientMock.WatchFilesCalls()), 2) + + service.CloseFile("/home/projects/TS/p1/src/index.ts") + service.OpenFile("/home/projects/TS/p3/src/index.ts", files["/home/projects/TS/p3/src/index.ts"].(string), core.ScriptKindTS, "") + assert.Equal(t, len(service.Projects()), 2) + assert.Assert(t, service.ConfiguredProject(tspath.ToPath("/home/projects/TS/p1/tsconfig.json", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) == nil) + assert.Assert(t, service.ConfiguredProject(tspath.ToPath("/home/projects/TS/p2/tsconfig.json", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) != nil) + assert.Assert(t, service.ConfiguredProject(tspath.ToPath("/home/projects/TS/p3/tsconfig.json", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) != nil) + assert.Assert(t, service.GetScriptInfoByPath(tspath.ToPath("/home/projects/TS/p1/src/index.ts", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) == nil) + assert.Assert(t, service.GetScriptInfoByPath(tspath.ToPath("/home/projects/TS/p1/src/x.ts", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) == nil) + assert.Equal(t, len(host.ClientMock.WatchFilesCalls()), 3) + assert.Equal(t, len(host.ClientMock.UnwatchFilesCalls()), 1) + + service.CloseFile("/home/projects/TS/p2/src/index.ts") + service.CloseFile("/home/projects/TS/p3/src/index.ts") + service.OpenFile("/home/projects/TS/p1/src/index.ts", files["/home/projects/TS/p1/src/index.ts"].(string), core.ScriptKindTS, "") + assert.Assert(t, service.ConfiguredProject(tspath.ToPath("/home/projects/TS/p1/tsconfig.json", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) != nil) + assert.Assert(t, service.ConfiguredProject(tspath.ToPath("/home/projects/TS/p2/tsconfig.json", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) == nil) + assert.Assert(t, service.ConfiguredProject(tspath.ToPath("/home/projects/TS/p3/tsconfig.json", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) == nil) + assert.Assert(t, service.GetScriptInfoByPath(tspath.ToPath("/home/projects/TS/p2/src/index.ts", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) == nil) + assert.Assert(t, service.GetScriptInfoByPath(tspath.ToPath("/home/projects/TS/p2/src/x.ts", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) == nil) + assert.Assert(t, service.GetScriptInfoByPath(tspath.ToPath("/home/projects/TS/p3/src/index.ts", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) == nil) + assert.Assert(t, service.GetScriptInfoByPath(tspath.ToPath("/home/projects/TS/p3/src/x.ts", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) == nil) + assert.Equal(t, len(host.ClientMock.WatchFilesCalls()), 4) + assert.Equal(t, len(host.ClientMock.UnwatchFilesCalls()), 3) + }) + + t.Run("inferred projects", func(t *testing.T) { + t.Parallel() + files := map[string]any{ + "/home/projects/TS/p1/src/index.ts": `import { x } from "./x";`, + "/home/projects/TS/p1/src/x.ts": `export const x = 1;`, + "/home/projects/TS/p1/config.ts": `let x = 1, y = 2;`, + "/home/projects/TS/p2/src/index.ts": `import { x } from "./x";`, + "/home/projects/TS/p2/src/x.ts": `export const x = 1;`, + "/home/projects/TS/p2/config.ts": `let x = 1, y = 2;`, + "/home/projects/TS/p3/src/index.ts": `import { x } from "./x";`, + "/home/projects/TS/p3/src/x.ts": `export const x = 1;`, + "/home/projects/TS/p3/config.ts": `let x = 1, y = 2;`, + } + service, host := projecttestutil.Setup(files, nil) + assert.Equal(t, len(service.Projects()), 0) + service.OpenFile("/home/projects/TS/p1/src/index.ts", files["/home/projects/TS/p1/src/index.ts"].(string), core.ScriptKindTS, "/home/projects/TS/p1") + service.OpenFile("/home/projects/TS/p2/src/index.ts", files["/home/projects/TS/p2/src/index.ts"].(string), core.ScriptKindTS, "/home/projects/TS/p2") + assert.Equal(t, len(service.Projects()), 2) + assert.Assert(t, service.InferredProject(tspath.ToPath("/home/projects/TS/p1", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) != nil) + assert.Assert(t, service.InferredProject(tspath.ToPath("/home/projects/TS/p2", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) != nil) + + service.CloseFile("/home/projects/TS/p1/src/index.ts") + service.OpenFile("/home/projects/TS/p3/src/index.ts", files["/home/projects/TS/p3/src/index.ts"].(string), core.ScriptKindTS, "/home/projects/TS/p3") + assert.Equal(t, len(service.Projects()), 2) + assert.Assert(t, service.InferredProject(tspath.ToPath("/home/projects/TS/p1", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) == nil) + assert.Assert(t, service.InferredProject(tspath.ToPath("/home/projects/TS/p2", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) != nil) + assert.Assert(t, service.InferredProject(tspath.ToPath("/home/projects/TS/p3", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) != nil) + assert.Assert(t, service.GetScriptInfoByPath(tspath.ToPath("/home/projects/TS/p1/src/index.ts", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) == nil) + + service.CloseFile("/home/projects/TS/p2/src/index.ts") + service.CloseFile("/home/projects/TS/p3/src/index.ts") + service.OpenFile("/home/projects/TS/p1/src/index.ts", files["/home/projects/TS/p1/src/index.ts"].(string), core.ScriptKindTS, "/home/projects/TS/p1") + assert.Assert(t, service.InferredProject(tspath.ToPath("/home/projects/TS/p1", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) != nil) + assert.Assert(t, service.InferredProject(tspath.ToPath("/home/projects/TS/p2", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) == nil) + assert.Assert(t, service.InferredProject(tspath.ToPath("/home/projects/TS/p3", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) == nil) + assert.Assert(t, service.GetScriptInfoByPath(tspath.ToPath("/home/projects/TS/p2/src/index.ts", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) == nil) + assert.Assert(t, service.GetScriptInfoByPath(tspath.ToPath("/home/projects/TS/p3/src/index.ts", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) == nil) + }) + + t.Run("unrooted inferred projects", func(t *testing.T) { + t.Parallel() + files := map[string]any{ + "/home/projects/TS/p1/src/index.ts": `import { x } from "./x";`, + "/home/projects/TS/p1/src/x.ts": `export const x = 1;`, + "/home/projects/TS/p1/config.ts": `let x = 1, y = 2;`, + "/home/projects/TS/p2/src/index.ts": `import { x } from "./x";`, + "/home/projects/TS/p2/src/x.ts": `export const x = 1;`, + "/home/projects/TS/p2/config.ts": `let x = 1, y = 2;`, + "/home/projects/TS/p3/src/index.ts": `import { x } from "./x";`, + "/home/projects/TS/p3/src/x.ts": `export const x = 1;`, + "/home/projects/TS/p3/config.ts": `let x = 1, y = 2;`, + } + service, host := projecttestutil.Setup(files, nil) + assert.Equal(t, len(service.Projects()), 0) + service.OpenFile("/home/projects/TS/p1/src/index.ts", files["/home/projects/TS/p1/src/index.ts"].(string), core.ScriptKindTS, "") + service.OpenFile("/home/projects/TS/p2/src/index.ts", files["/home/projects/TS/p2/src/index.ts"].(string), core.ScriptKindTS, "") + assert.Equal(t, len(service.Projects()), 1) + assert.Assert(t, service.InferredProject(tspath.Path("")) != nil) + + service.CloseFile("/home/projects/TS/p1/src/index.ts") + service.OpenFile("/home/projects/TS/p3/src/index.ts", files["/home/projects/TS/p3/src/index.ts"].(string), core.ScriptKindTS, "") + assert.Equal(t, len(service.Projects()), 1) + assert.Assert(t, service.InferredProject(tspath.Path("")) != nil) + assert.Assert(t, service.GetScriptInfoByPath(tspath.ToPath("/home/projects/TS/p1/src/index.ts", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) == nil) + + service.CloseFile("/home/projects/TS/p2/src/index.ts") + service.CloseFile("/home/projects/TS/p3/src/index.ts") + service.OpenFile("/home/projects/TS/p1/src/index.ts", files["/home/projects/TS/p1/src/index.ts"].(string), core.ScriptKindTS, "") + assert.Assert(t, service.InferredProject(tspath.Path("")) != nil) + assert.Assert(t, service.GetScriptInfoByPath(tspath.ToPath("/home/projects/TS/p2/src/index.ts", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) == nil) + assert.Assert(t, service.GetScriptInfoByPath(tspath.ToPath("/home/projects/TS/p3/src/index.ts", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) == nil) + + service.CloseFile("/home/projects/TS/p1/src/index.ts") + service.OpenFile("/home/projects/TS/p2/src/index.ts", files["/home/projects/TS/p2/src/index.ts"].(string), core.ScriptKindTS, "/home/projects/TS/p2") + assert.Equal(t, len(service.Projects()), 1) + assert.Assert(t, service.InferredProject(tspath.Path("")) == nil) + assert.Assert(t, service.GetScriptInfoByPath(tspath.ToPath("/home/projects/TS/p1/src/index.ts", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) == nil) + assert.Assert(t, service.InferredProject(tspath.ToPath("/home/projects/TS/p2", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) != nil) + }) +} diff --git a/internal/project/scriptinfo.go b/internal/project/scriptinfo.go index 2e6206c3db..9a8fd9f515 100644 --- a/internal/project/scriptinfo.go +++ b/internal/project/scriptinfo.go @@ -2,6 +2,7 @@ package project import ( "slices" + "sync" "github.com/microsoft/typescript-go/internal/core" "github.com/microsoft/typescript-go/internal/ls" @@ -21,12 +22,12 @@ type ScriptInfo struct { version int lineMap *ls.LineMap - isOpen bool pendingReloadFromDisk bool matchesDiskText bool deferredDelete bool - containingProjects []*Project + containingProjectsMu sync.RWMutex + containingProjects []*Project fs vfs.FS } @@ -69,6 +70,12 @@ func (s *ScriptInfo) Version() int { return s.version } +func (s *ScriptInfo) ContainingProjects() []*Project { + s.containingProjectsMu.RLock() + defer s.containingProjectsMu.RUnlock() + return slices.Clone(s.containingProjects) +} + func (s *ScriptInfo) reloadIfNeeded() { if s.pendingReloadFromDisk { if newText, ok := s.fs.ReadFile(s.fileName); ok { @@ -78,7 +85,6 @@ func (s *ScriptInfo) reloadIfNeeded() { } func (s *ScriptInfo) open(newText string) { - s.isOpen = true s.pendingReloadFromDisk = false if newText != s.text { s.setText(newText) @@ -95,11 +101,19 @@ func (s *ScriptInfo) SetTextFromDisk(newText string) { } func (s *ScriptInfo) close(fileExists bool) { - s.isOpen = false if fileExists && !s.pendingReloadFromDisk && !s.matchesDiskText { s.pendingReloadFromDisk = true s.markContainingProjectsAsDirty() } + + s.containingProjectsMu.Lock() + defer s.containingProjectsMu.Unlock() + for _, project := range slices.Clone(s.containingProjects) { + if project.kind == KindInferred && project.isRoot(s) { + project.RemoveFile(s, fileExists) + s.detachFromProjectLocked(project) + } + } } func (s *ScriptInfo) setText(newText string) { @@ -109,6 +123,8 @@ func (s *ScriptInfo) setText(newText string) { } func (s *ScriptInfo) markContainingProjectsAsDirty() { + s.containingProjectsMu.RLock() + defer s.containingProjectsMu.RUnlock() for _, project := range s.containingProjects { project.MarkFileAsDirty(s.path) } @@ -117,18 +133,30 @@ func (s *ScriptInfo) markContainingProjectsAsDirty() { // attachToProject attaches the script info to the project if it's not already attached // and returns true if the script info was newly attached. func (s *ScriptInfo) attachToProject(project *Project) bool { - if !s.isAttached(project) { - s.containingProjects = append(s.containingProjects, project) - if project.compilerOptions.PreserveSymlinks != core.TSTrue { - s.ensureRealpath(project.FS()) - } - project.onFileAddedOrRemoved() - return true + if s.isAttached(project) { + return false + } + s.containingProjectsMu.Lock() + if s.isAttachedLocked(project) { + s.containingProjectsMu.Unlock() + return false + } + s.containingProjects = append(s.containingProjects, project) + s.containingProjectsMu.Unlock() + if project.compilerOptions.PreserveSymlinks != core.TSTrue { + s.ensureRealpath(project) } - return false + project.onFileAddedOrRemoved() + return true } func (s *ScriptInfo) isAttached(project *Project) bool { + s.containingProjectsMu.RLock() + defer s.containingProjectsMu.RUnlock() + return s.isAttachedLocked(project) +} + +func (s *ScriptInfo) isAttachedLocked(project *Project) bool { return slices.Contains(s.containingProjects, project) } @@ -136,6 +164,8 @@ func (s *ScriptInfo) isOrphan() bool { if s.deferredDelete { return true } + s.containingProjectsMu.RLock() + defer s.containingProjectsMu.RUnlock() for _, project := range s.containingProjects { if !project.isOrphan() { return false @@ -149,13 +179,9 @@ func (s *ScriptInfo) editContent(change ls.TextChange) { s.markContainingProjectsAsDirty() } -func (s *ScriptInfo) ensureRealpath(fs vfs.FS) { +func (s *ScriptInfo) ensureRealpath(project *Project) { if s.realpath == "" { - if len(s.containingProjects) == 0 { - panic("scriptInfo must be attached to a project before calling ensureRealpath") - } - realpath := fs.Realpath(string(s.path)) - project := s.containingProjects[0] + realpath := project.FS().Realpath(string(s.path)) s.realpath = project.toPath(realpath) if s.realpath != s.path { project.host.OnDiscoveredSymlink(s) @@ -171,17 +197,25 @@ func (s *ScriptInfo) getRealpathIfDifferent() (tspath.Path, bool) { } func (s *ScriptInfo) detachAllProjects() { + s.containingProjectsMu.Lock() + defer s.containingProjectsMu.Unlock() for _, project := range s.containingProjects { // !!! // if (isConfiguredProject(p)) { // p.getCachedDirectoryStructureHost().addOrDeleteFile(this.fileName, this.path, FileWatcherEventKind.Deleted); // } - project.RemoveFile(s, false /*fileExists*/, false /*detachFromProject*/) + project.RemoveFile(s, false /*fileExists*/) } s.containingProjects = nil } func (s *ScriptInfo) detachFromProject(project *Project) { + s.containingProjectsMu.Lock() + defer s.containingProjectsMu.Unlock() + s.detachFromProjectLocked(project) +} + +func (s *ScriptInfo) detachFromProjectLocked(project *Project) { if index := slices.Index(s.containingProjects, project); index != -1 { s.containingProjects = slices.Delete(s.containingProjects, index, index+1) } @@ -194,3 +228,11 @@ func (s *ScriptInfo) delayReloadNonMixedContentFile() { s.pendingReloadFromDisk = true s.markContainingProjectsAsDirty() } + +func (s *ScriptInfo) containedByDeferredClosedProject() bool { + s.containingProjectsMu.RLock() + defer s.containingProjectsMu.RUnlock() + return slices.ContainsFunc(s.containingProjects, func(project *Project) bool { + return project.deferredClose + }) +} diff --git a/internal/project/service.go b/internal/project/service.go index 3ec6f02f94..2327bfe953 100644 --- a/internal/project/service.go +++ b/internal/project/service.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "maps" + "runtime" "strings" "sync" @@ -23,12 +25,6 @@ const ( projectLoadKindReload ) -type assignProjectResult struct { - configFileName string - retainProjects map[*Project]projectLoadKind - // configFileErrors []*ast.Diagnostic -} - type ServiceOptions struct { TypingsInstallerOptions Logger *Logger @@ -44,18 +40,18 @@ type Service struct { comparePathsOptions tspath.ComparePathsOptions converters *ls.Converters + projectsMu sync.RWMutex configuredProjects map[tspath.Path]*Project - // unrootedInferredProject is the inferred project for files opened without a projectRootDirectory - // (e.g. dynamic files) - unrootedInferredProject *Project // inferredProjects is the list of all inferred projects, including the unrootedInferredProject // if it exists - inferredProjects []*Project + inferredProjects map[tspath.Path]*Project + + documentRegistry *DocumentRegistry + scriptInfosMu sync.RWMutex + scriptInfos map[tspath.Path]*ScriptInfo + openFiles map[tspath.Path]string // values are projectRootPath, if provided + configFileForOpenFiles map[tspath.Path]string // default config project for open files !!! todo solution and project reference handling - documentRegistry *DocumentRegistry - scriptInfosMu sync.RWMutex - scriptInfos map[tspath.Path]*ScriptInfo - openFiles map[tspath.Path]string // values are projectRootPath, if provided // Contains all the deleted script info's version information so that // it does not reset when creating script info again filenameToScriptInfoVersion map[tspath.Path]int @@ -78,6 +74,7 @@ func NewService(host ServiceHost, options ServiceOptions) *Service { }, configuredProjects: make(map[tspath.Path]*Project), + inferredProjects: make(map[tspath.Path]*Project), documentRegistry: &DocumentRegistry{ Options: tspath.ComparePathsOptions{ @@ -87,6 +84,7 @@ func NewService(host ServiceHost, options ServiceOptions) *Service { }, scriptInfos: make(map[tspath.Path]*ScriptInfo), openFiles: make(map[tspath.Path]string), + configFileForOpenFiles: make(map[tspath.Path]string), filenameToScriptInfoVersion: make(map[tspath.Path]int), realpathToScriptInfos: make(map[tspath.Path]map[*ScriptInfo]struct{}), } @@ -168,14 +166,36 @@ func (s *Service) IsWatchEnabled() bool { } func (s *Service) Projects() []*Project { + s.projectsMu.RLock() + defer s.projectsMu.RUnlock() projects := make([]*Project, 0, len(s.configuredProjects)+len(s.inferredProjects)) for _, project := range s.configuredProjects { projects = append(projects, project) } - projects = append(projects, s.inferredProjects...) + for _, project := range s.inferredProjects { + projects = append(projects, project) + } return projects } +func (s *Service) ConfiguredProject(path tspath.Path) *Project { + s.projectsMu.RLock() + defer s.projectsMu.RUnlock() + if project, ok := s.configuredProjects[path]; ok { + return project + } + return nil +} + +func (s *Service) InferredProject(rootPath tspath.Path) *Project { + s.projectsMu.RLock() + defer s.projectsMu.RUnlock() + if project, ok := s.inferredProjects[rootPath]; ok { + return project + } + return nil +} + func (s *Service) GetScriptInfo(fileName string) *ScriptInfo { return s.GetScriptInfoByPath(s.toPath(fileName)) } @@ -189,16 +209,26 @@ func (s *Service) GetScriptInfoByPath(path tspath.Path) *ScriptInfo { return nil } +func (s *Service) isOpenFile(info *ScriptInfo) bool { + _, ok := s.openFiles[info.path] + return ok +} + func (s *Service) OpenFile(fileName string, fileContent string, scriptKind core.ScriptKind, projectRootPath string) { path := s.toPath(fileName) existing := s.GetScriptInfoByPath(path) info := s.getOrCreateOpenScriptInfo(fileName, path, fileContent, scriptKind, projectRootPath) if existing == nil && info != nil && !info.isDynamic { - // !!! - // s.tryInvokeWildcardDirectories(info) + // Invoke wild card directory watcher to ensure that the file presence is reflected + s.projectsMu.RLock() + for _, project := range s.configuredProjects { + project.tryInvokeWildCardDirectories(fileName, info.path) + } + s.projectsMu.RUnlock() } result := s.assignProjectToOpenedScriptInfo(info) - s.cleanupProjectsAndScriptInfos(result.retainProjects, []tspath.Path{info.path}) + s.cleanupProjectsAndScriptInfos(info, result) + s.printMemoryUsage() s.printProjects() } @@ -232,12 +262,8 @@ func (s *Service) CloseFile(fileName string) { if info := s.GetScriptInfoByPath(s.toPath(fileName)); info != nil { fileExists := !info.isDynamic && s.host.FS().FileExists(info.fileName) info.close(fileExists) - for _, project := range info.containingProjects { - if project.kind == KindInferred && project.isRoot(info) { - project.RemoveFile(info, fileExists, true /*detachFromProject*/) - } - } delete(s.openFiles, info.path) + delete(s.configFileForOpenFiles, info.path) if !fileExists { s.handleDeletedFile(info, false /*deferredDelete*/) } @@ -281,6 +307,8 @@ func (s *Service) SourceFileCount() int { } func (s *Service) OnWatchedFilesChanged(ctx context.Context, changes []*lsproto.FileEvent) error { + s.projectsMu.RLock() + defer s.projectsMu.RUnlock() for _, change := range changes { fileName := ls.DocumentURIToFileName(change.Uri) path := s.toPath(fileName) @@ -332,20 +360,23 @@ func (s *Service) onConfigFileChanged(project *Project, changeKind lsproto.FileC } if !project.deferredClose { - project.pendingReload = PendingReloadFull - project.markAsDirty() + project.SetPendingReload(PendingReloadFull) } return nil } func (s *Service) ensureProjectStructureUpToDate() { var hasChanges bool + s.projectsMu.RLock() for _, project := range s.configuredProjects { - hasChanges = project.updateGraph() || hasChanges + _, updated := project.updateGraph() + hasChanges = updated || hasChanges } for _, project := range s.inferredProjects { - hasChanges = project.updateGraph() || hasChanges + _, updated := project.updateGraph() + hasChanges = updated || hasChanges } + s.projectsMu.RUnlock() if hasChanges { s.ensureProjectForOpenFiles() } @@ -366,9 +397,11 @@ func (s *Service) ensureProjectForOpenFiles() { // !!! s.removeRootOfInferredProjectIfNowPartOfOtherProject(info) } } + s.projectsMu.RLock() for _, project := range s.inferredProjects { project.updateGraph() } + s.projectsMu.RUnlock() s.Log("After ensureProjectForOpenFiles:") s.printProjects() @@ -381,12 +414,13 @@ func (s *Service) applyChangesToFile(info *ScriptInfo, changes []ls.TextChange) } func (s *Service) handleDeletedFile(info *ScriptInfo, deferredDelete bool) { - if info.isOpen { + if s.isOpenFile(info) { panic("cannot delete an open file") } // !!! // s.handleSourceMapProjects(info) + containingProjects := info.ContainingProjects() info.detachAllProjects() if deferredDelete { info.delayReloadNonMixedContentFile() @@ -394,15 +428,19 @@ func (s *Service) handleDeletedFile(info *ScriptInfo, deferredDelete bool) { } else { s.deleteScriptInfo(info) } - s.updateProjectGraphs(info.containingProjects, false /*clearSourceMapperCache*/) + s.updateProjectGraphs(containingProjects, false /*clearSourceMapperCache*/) } func (s *Service) deleteScriptInfo(info *ScriptInfo) { - if info.isOpen { + if s.isOpenFile(info) { panic("cannot delete an open file") } s.scriptInfosMu.Lock() defer s.scriptInfosMu.Unlock() + s.deleteScriptInfoLocked(info) +} + +func (s *Service) deleteScriptInfoLocked(info *ScriptInfo) { delete(s.scriptInfos, info.path) s.filenameToScriptInfoVersion[info.path] = info.version // !!! @@ -501,13 +539,15 @@ func (s *Service) configFileExists(configFilename string) bool { } func (s *Service) getConfigFileNameForFile(info *ScriptInfo, findFromCacheOnly bool) string { - // !!! - // const fromCache = this.getConfigFileNameForFileFromCache(info, findFromCacheOnly); - // if (fromCache !== undefined) return fromCache || undefined; - // if (findFromCacheOnly) return undefined; - // - // !!! - // good grief, this is convoluted. I'm skipping so much stuff right now + configName, ok := s.configFileForOpenFiles[info.path] + if ok { + return configName + } + + if findFromCacheOnly { + return "" + } + projectRootPath := s.openFiles[info.path] if info.isDynamic { return "" @@ -532,6 +572,10 @@ func (s *Service) getConfigFileNameForFile(info *ScriptInfo, findFromCacheOnly b return "", false }) s.logf("getConfigFileNameForFile:: File: %s ProjectRootPath: %s:: Result: %s", info.fileName, s.openFiles[info.path], fileName) + + if _, ok := s.openFiles[info.path]; ok { + s.configFileForOpenFiles[info.path] = fileName + } return fileName } @@ -540,6 +584,8 @@ func (s *Service) findDefaultConfiguredProject(scriptInfo *ScriptInfo) *Project } func (s *Service) findConfiguredProjectByName(configFilePath tspath.Path, includeDeferredClosedProjects bool) *Project { + s.projectsMu.RLock() + defer s.projectsMu.RUnlock() if result, ok := s.configuredProjects[configFilePath]; ok { if includeDeferredClosedProjects || !result.deferredClose { return result @@ -549,6 +595,9 @@ func (s *Service) findConfiguredProjectByName(configFilePath tspath.Path, includ } func (s *Service) createConfiguredProject(configFileName string, configFilePath tspath.Path) *Project { + s.projectsMu.Lock() + defer s.projectsMu.Unlock() + // !!! config file existence cache stuff omitted project := NewConfiguredProject(configFileName, configFilePath, s) s.configuredProjects[configFilePath] = project @@ -568,7 +617,7 @@ func (s *Service) findCreateOrReloadConfiguredProject(configFileName string, pro if project == nil { project = s.createConfiguredProject(configFileName, configFilePath) } - s.loadConfiguredProject(project) + project.updateGraph() default: panic("unhandled projectLoadKind") } @@ -592,13 +641,13 @@ func (s *Service) tryFindDefaultConfiguredProjectAndLoadAncestorsForOpenScriptIn return result } -func (s *Service) assignProjectToOpenedScriptInfo(info *ScriptInfo) assignProjectResult { - var result assignProjectResult +func (s *Service) assignProjectToOpenedScriptInfo(info *ScriptInfo) *Project { + // !!! todo retain projects list when its multiple projects that are looked up + var result *Project if project := s.tryFindDefaultConfiguredProjectAndLoadAncestorsForOpenScriptInfo(info, projectLoadKindCreate); project != nil { - result.configFileName = project.configFileName - // result.configFileErrors = project.getAllProjectErrors() + result = project } - for _, project := range info.containingProjects { + for _, project := range info.ContainingProjects() { project.updateGraph() } if info.isOrphan() { @@ -613,70 +662,189 @@ func (s *Service) assignProjectToOpenedScriptInfo(info *ScriptInfo) assignProjec return result } -func (s *Service) cleanupProjectsAndScriptInfos(toRetainConfiguredProjects map[*Project]projectLoadKind, openFilesWithRetainedConfiguredProject []tspath.Path) { - // !!! +func (s *Service) cleanupProjectsAndScriptInfos(openInfo *ScriptInfo, retainedByOpenFile *Project) { + // This was postponed from closeOpenFile to after opening next file, + // so that we can reuse the project if we need to right away + // Remove all the non marked projects + s.cleanupConfiguredProjects(openInfo, retainedByOpenFile) + + // Remove orphan inferred projects now that we have reused projects + // We need to create a duplicate because we cant guarantee order after removal + s.projectsMu.RLock() + inferredProjects := maps.Clone(s.inferredProjects) + s.projectsMu.RUnlock() + for _, inferredProject := range inferredProjects { + if inferredProject.isOrphan() { + s.removeProject(inferredProject) + } + } + + // Delete the orphan files here because there might be orphan script infos (which are not part of project) + // when some file/s were closed which resulted in project removal. + // It was then postponed to cleanup these script infos so that they can be reused if + // the file from that old project is reopened because of opening file from here. + s.removeOrphanScriptInfos() } -func (s *Service) assignOrphanScriptInfoToInferredProject(info *ScriptInfo, projectRootDirectory string) { +func (s *Service) cleanupConfiguredProjects(openInfo *ScriptInfo, retainedByOpenFile *Project) { + s.projectsMu.RLock() + toRemoveProjects := maps.Clone(s.configuredProjects) + s.projectsMu.RUnlock() + + // !!! handle declarationMap + retainConfiguredProject := func(project *Project) { + if _, ok := toRemoveProjects[project.configFilePath]; !ok { + return + } + delete(toRemoveProjects, project.configFilePath) + // // Keep original projects used + // markOriginalProjectsAsUsed(project); + // // Keep all the references alive + // forEachReferencedProject(project, retainConfiguredProject); + } + + if retainedByOpenFile != nil { + retainConfiguredProject(retainedByOpenFile) + } + + // Everything needs to be retained, fast path to skip all the work + if len(toRemoveProjects) == 0 { + return + } + + // Retain default configured project for open script info + for path := range s.openFiles { + if path == openInfo.path { + continue + } + info := s.GetScriptInfoByPath(path) + // We want to retain the projects for open file if they are pending updates so deferredClosed projects are ok + result := s.tryFindDefaultConfiguredProjectAndLoadAncestorsForOpenScriptInfo( + info, + projectLoadKindFind, + ) + if result != nil { + retainConfiguredProject(result) + // Everything needs to be retained, fast path to skip all the work + if len(toRemoveProjects) == 0 { + return + } + } + } + + // !!! project references + + for _, project := range toRemoveProjects { + s.removeProject(project) + } +} + +func (s *Service) removeProject(project *Project) { + s.Log("remove Project:: " + project.name) + s.Log(project.print( /*writeProjectFileNames*/ true /*writeFileExplaination*/, true /*writeFileVersionAndText*/, false, &strings.Builder{})) + s.projectsMu.Lock() + switch project.kind { + case KindConfigured: + delete(s.configuredProjects, project.configFilePath) + case KindInferred: + delete(s.inferredProjects, project.rootPath) + } + s.projectsMu.Unlock() + project.Close() +} + +func (s *Service) removeOrphanScriptInfos() { + s.scriptInfosMu.Lock() + defer s.scriptInfosMu.Unlock() + + toRemoveScriptInfos := maps.Clone(s.scriptInfos) + + for _, info := range s.scriptInfos { + if info.deferredDelete { + continue + } + + // If script info is not open and orphan, remove it + if !s.isOpenFile(info) && + info.isOrphan() && + // !scriptInfoIsContainedByBackgroundProject(info) && + !info.containedByDeferredClosedProject() { + // !!! dts map related infos and code + continue + } + // Retain this script info + delete(toRemoveScriptInfos, info.path) + } + + // if there are not projects that include this script info - delete it + for _, info := range toRemoveScriptInfos { + s.deleteScriptInfoLocked(info) + } +} + +func (s *Service) assignOrphanScriptInfoToInferredProject(info *ScriptInfo, projectRootDirectory string) *Project { if !info.isOrphan() { panic("scriptInfo is not orphan") } project := s.getOrCreateInferredProjectForProjectRootPath(info, projectRootDirectory) - if project == nil { - project = s.getOrCreateUnrootedInferredProject() - } - - project.AddRoot(info) + project.AddInferredProjectRoot(info) project.updateGraph() + return project // !!! old code ensures that scriptInfo is only part of one project } -func (s *Service) getOrCreateUnrootedInferredProject() *Project { - if s.unrootedInferredProject == nil { - s.unrootedInferredProject = s.createInferredProject(s.host.GetCurrentDirectory(), "") +func (s *Service) getOrCreateInferredProjectForProjectRootPath(info *ScriptInfo, projectRootDirectory string) *Project { + project := s.getInferredProjectForProjectRootPath(info, projectRootDirectory) + if project != nil { + return project + } + if projectRootDirectory != "" { + return s.createInferredProject(projectRootDirectory, s.toPath(projectRootDirectory)) } - return s.unrootedInferredProject + return s.createInferredProject(s.host.GetCurrentDirectory(), "") } -func (s *Service) getOrCreateInferredProjectForProjectRootPath(info *ScriptInfo, projectRootDirectory string) *Project { - if info.isDynamic && projectRootDirectory == "" { +func (s *Service) getInferredProjectForProjectRootPath(info *ScriptInfo, projectRootDirectory string) *Project { + s.projectsMu.RLock() + defer s.projectsMu.RUnlock() + if projectRootDirectory != "" { + projectRootPath := s.toPath(projectRootDirectory) + if project, ok := s.inferredProjects[projectRootPath]; ok { + return project + } return nil } - if projectRootDirectory != "" { - projectRootPath := s.toPath(projectRootDirectory) + if !info.isDynamic { + var bestMatch *Project for _, project := range s.inferredProjects { - if project.rootPath == projectRootPath { - return project + if project.rootPath != "" && + tspath.ContainsPath(string(project.rootPath), string(info.path), s.comparePathsOptions) && + (bestMatch == nil || len(bestMatch.rootPath) <= len(project.rootPath)) { + bestMatch = project } } - return s.createInferredProject(projectRootDirectory, projectRootPath) - } - var bestMatch *Project - for _, project := range s.inferredProjects { - if project.rootPath == "" { - continue + if bestMatch != nil { + return bestMatch } - if !tspath.ContainsPath(string(project.rootPath), string(info.path), s.comparePathsOptions) { - continue - } - if bestMatch != nil && len(bestMatch.rootPath) > len(project.rootPath) { - continue - } - bestMatch = project } - return bestMatch + // unrooted inferred project if no best match found + if unrootedProject, ok := s.inferredProjects[""]; ok { + return unrootedProject + } + return nil } func (s *Service) getDefaultProjectForScript(scriptInfo *ScriptInfo) *Project { - switch len(scriptInfo.containingProjects) { + containingProjects := scriptInfo.ContainingProjects() + switch len(containingProjects) { case 0: panic("scriptInfo must be attached to a project before calling getDefaultProject") case 1: - project := scriptInfo.containingProjects[0] + project := containingProjects[0] if project.deferredClose || project.kind == KindAutoImportProvider || project.kind == KindAuxiliary { panic("scriptInfo must be attached to a non-background project before calling getDefaultProject") } @@ -693,13 +861,13 @@ func (s *Service) getDefaultProjectForScript(scriptInfo *ScriptInfo) *Project { var firstNonSourceOfProjectReferenceRedirect *Project var defaultConfiguredProject *Project - for index, project := range scriptInfo.containingProjects { + for index, project := range containingProjects { if project.kind == KindConfigured { if project.deferredClose { continue } // !!! if !project.isSourceOfProjectReferenceRedirect(scriptInfo.fileName) { - if defaultConfiguredProject == nil && index != len(scriptInfo.containingProjects)-1 { + if defaultConfiguredProject == nil && index != len(containingProjects)-1 { defaultConfiguredProject = s.findDefaultConfiguredProject(scriptInfo) } if defaultConfiguredProject == project { @@ -733,6 +901,12 @@ func (s *Service) getDefaultProjectForScript(scriptInfo *ScriptInfo) *Project { } func (s *Service) createInferredProject(currentDirectory string, projectRootPath tspath.Path) *Project { + s.projectsMu.Lock() + defer s.projectsMu.Unlock() + if existingProject, ok := s.inferredProjects[projectRootPath]; ok { + return existingProject + } + compilerOptions := core.CompilerOptions{ AllowJs: core.TSTrue, Module: core.ModuleKindESNext, @@ -748,7 +922,7 @@ func (s *Service) createInferredProject(currentDirectory string, projectRootPath ResolveJsonModule: core.TSTrue, } project := NewInferredProject(&compilerOptions, currentDirectory, projectRootPath, s) - s.inferredProjects = append(s.inferredProjects, project) + s.inferredProjects[project.rootPath] = project return project } @@ -756,34 +930,40 @@ func (s *Service) toPath(fileName string) tspath.Path { return tspath.ToPath(fileName, s.host.GetCurrentDirectory(), s.host.FS().UseCaseSensitiveFileNames()) } -func (s *Service) loadConfiguredProject(project *Project) { - if err := project.LoadConfig(); err != nil { - panic(fmt.Errorf("failed to load project %q: %w", project.configFileName, err)) - } -} - func (s *Service) printProjects() { if !s.options.Logger.HasLevel(LogLevelNormal) { return } var builder strings.Builder + s.projectsMu.RLock() for _, project := range s.configuredProjects { project.print(false /*writeFileNames*/, false /*writeFileExpanation*/, false /*writeFileVersionAndText*/, &builder) + builder.WriteRune('\n') } for _, project := range s.inferredProjects { project.print(false /*writeFileNames*/, false /*writeFileExpanation*/, false /*writeFileVersionAndText*/, &builder) + builder.WriteRune('\n') } + s.projectsMu.RUnlock() - builder.WriteString("Open files: ") + builder.WriteString("Open files:") for path, projectRootPath := range s.openFiles { info := s.GetScriptInfoByPath(path) - builder.WriteString(fmt.Sprintf("\tFileName: %s ProjectRootPath: %s", info.fileName, projectRootPath)) - builder.WriteString("\t\tProjects: " + strings.Join(core.Map(info.containingProjects, func(project *Project) string { return project.name }), ", ")) + builder.WriteString(fmt.Sprintf("\n\tFileName: %s ProjectRootPath: %s", info.fileName, projectRootPath)) + builder.WriteString("\n\t\tProjects: " + strings.Join(core.Map(info.ContainingProjects(), func(project *Project) string { return project.name }), ", ")) } + builder.WriteString("\n" + hr) s.Log(builder.String()) } func (s *Service) logf(format string, args ...any) { s.Log(fmt.Sprintf(format, args...)) } + +func (s *Service) printMemoryUsage() { + runtime.GC() // Force garbage collection to get accurate memory stats + var memStats runtime.MemStats + runtime.ReadMemStats(&memStats) + s.logf("MemoryStats:\n\tAlloc: %v KB\n\tSys: %v KB\n\tNumGC: %v", memStats.Alloc/1024, memStats.Sys/1024, memStats.NumGC) +} diff --git a/internal/project/watch.go b/internal/project/watch.go index ffab39cdda..d55f874d7a 100644 --- a/internal/project/watch.go +++ b/internal/project/watch.go @@ -44,33 +44,27 @@ func newWatchedFiles[T any]( } func (w *watchedFiles[T]) update(ctx context.Context, newData T) { - if updated, err := w.updateWorker(ctx, newData); err != nil { - w.p.Log(fmt.Sprintf("Failed to update %s watch: %v\n%s", w.watchType, err, formatFileList(w.globs, "\t", hr))) - } else if updated { - w.p.Logf("%s watches updated %s:\n%s", w.watchType, w.watcherID, formatFileList(w.globs, "\t", hr)) - } -} - -func (w *watchedFiles[T]) updateWorker(ctx context.Context, newData T) (updated bool, err error) { newGlobs := w.getGlobs(newData) newGlobs = slices.Clone(newGlobs) slices.Sort(newGlobs) w.data = newData if slices.Equal(w.globs, newGlobs) { - return false, nil + return } w.globs = newGlobs if w.watcherID != "" { - if err = w.p.host.Client().UnwatchFiles(ctx, w.watcherID); err != nil { - return false, err + if err := w.p.host.Client().UnwatchFiles(ctx, w.watcherID); err != nil { + w.p.Log(fmt.Sprintf("%s:: Failed to unwatch %s watch: %s, err: %v newGlobs that are not updated: \n%s", w.p.name, w.watchType, w.watcherID, err, formatFileList(w.globs, "\t", hr))) + return } + w.p.Logf("%s:: %s watches unwatch %s", w.p.name, w.watchType, w.watcherID) } w.watcherID = "" if len(newGlobs) == 0 { - return true, nil + return } watchers := make([]*lsproto.FileSystemWatcher, 0, len(newGlobs)) @@ -84,10 +78,12 @@ func (w *watchedFiles[T]) updateWorker(ctx context.Context, newData T) (updated } watcherID, err := w.p.host.Client().WatchFiles(ctx, watchers) if err != nil { - return false, err + w.p.Log(fmt.Sprintf("%s:: Failed to update %s watch: %v\n%s", w.p.name, w.watchType, err, formatFileList(w.globs, "\t", hr))) + return } w.watcherID = watcherID - return true, nil + w.p.Logf("%s:: %s watches updated %s:\n%s", w.p.name, w.watchType, w.watcherID, formatFileList(w.globs, "\t", hr)) + return } func globMapperForTypingsInstaller(data map[tspath.Path]string) []string { diff --git a/internal/testutil/projecttestutil/projecttestutil.go b/internal/testutil/projecttestutil/projecttestutil.go index 6c5f8093ca..333bafc6cc 100644 --- a/internal/testutil/projecttestutil/projecttestutil.go +++ b/internal/testutil/projecttestutil/projecttestutil.go @@ -7,9 +7,11 @@ import ( "slices" "strings" "sync" + "sync/atomic" "github.com/microsoft/typescript-go/internal/bundled" "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/lsp/lsproto" "github.com/microsoft/typescript-go/internal/project" "github.com/microsoft/typescript-go/internal/tspath" "github.com/microsoft/typescript-go/internal/vfs" @@ -220,6 +222,10 @@ func newProjectServiceHost(files map[string]any) *ProjectServiceHost { defaultLibraryPath: bundled.LibPath(), ClientMock: &ClientMock{}, } + var watchCount atomic.Uint32 + host.ClientMock.WatchFilesFunc = func(_ context.Context, _ []*lsproto.FileSystemWatcher) (project.WatcherHandle, error) { + return project.WatcherHandle(fmt.Sprintf("#%d", watchCount.Add(1))), nil + } host.logger = project.NewLogger([]io.Writer{&host.output}, "", project.LogLevelVerbose) return host }