package project import ( "context" "fmt" "maps" "slices" "sync/atomic" "time" "github.com/microsoft/typescript-go/internal/collections" "github.com/microsoft/typescript-go/internal/core" "github.com/microsoft/typescript-go/internal/ls" "github.com/microsoft/typescript-go/internal/ls/autoimport" "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" "github.com/microsoft/typescript-go/internal/project/dirty" "github.com/microsoft/typescript-go/internal/project/logging" "github.com/microsoft/typescript-go/internal/sourcemap" "github.com/microsoft/typescript-go/internal/tspath" "github.com/microsoft/typescript-go/internal/vfs/vfsmatch" ) type Snapshot struct { id uint64 parentId uint64 refCount atomic.Int32 // Session options are immutable for the server lifetime, // so can be a pointer. sessionOptions *SessionOptions toPath func(fileName string) tspath.Path converters *lsconv.Converters // Immutable state, cloned between snapshots fs *SnapshotFS ProjectCollection *ProjectCollection ConfigFileRegistry *ConfigFileRegistry AutoImports *autoimport.Registry autoImportsWatch *WatchedFiles[map[tspath.Path]string] compilerOptionsForInferredProjects *core.CompilerOptions userPreferences lsutil.UserPreferences builderLogs *logging.LogTree apiError error } // NewSnapshot initializes a snapshot with refCount 1. // The caller is responsible for calling Deref when done. func NewSnapshot( id uint64, fs *SnapshotFS, sessionOptions *SessionOptions, configFileRegistry *ConfigFileRegistry, compilerOptionsForInferredProjects *core.CompilerOptions, userPreferences lsutil.UserPreferences, autoImports *autoimport.Registry, autoImportsWatch *WatchedFiles[map[tspath.Path]string], toPath func(fileName string) tspath.Path, ) *Snapshot { s := &Snapshot{ id: id, sessionOptions: sessionOptions, toPath: toPath, fs: fs, ConfigFileRegistry: configFileRegistry, ProjectCollection: &ProjectCollection{toPath: toPath, openFiles: openFilePaths(fs.overlays)}, compilerOptionsForInferredProjects: compilerOptionsForInferredProjects, userPreferences: userPreferences, AutoImports: autoImports, autoImportsWatch: autoImportsWatch, } s.refCount.Store(1) s.converters = lsconv.NewConverters(s.sessionOptions.PositionEncoding, s.LSPLineMap) return s } func (s *Snapshot) GetDefaultProject(uri lsproto.DocumentUri) *Project { return s.ProjectCollection.GetDefaultProject(uri.Path(s.UseCaseSensitiveFileNames())) } func (s *Snapshot) GetProjectsContainingFile(uri lsproto.DocumentUri) []ls.Project { fileName := uri.FileName() path := s.toPath(fileName) // TODO!! sheetal may be change this to handle symlinks!! return s.ProjectCollection.GetProjectsContainingFile(path) } func (s *Snapshot) GetFile(fileName string) FileHandle { return s.fs.GetFile(fileName) } func (s *Snapshot) LSPLineMap(fileName string) *lsconv.LSPLineMap { if file := s.fs.GetFile(fileName); file != nil { return file.LSPLineMap() } return nil } func (s *Snapshot) GetECMALineInfo(fileName string) *sourcemap.ECMALineInfo { if file := s.fs.GetFile(fileName); file != nil { return file.ECMALineInfo() } return nil } func (s *Snapshot) GetPreferences(activeFile string) lsutil.UserPreferences { return s.userPreferences } func (s *Snapshot) UserPreferences() lsutil.UserPreferences { return s.userPreferences } func (s *Snapshot) Converters() *lsconv.Converters { return s.converters } func (s *Snapshot) AutoImportRegistry() *autoimport.Registry { return s.AutoImports } func (s *Snapshot) ID() uint64 { return s.id } func (s *Snapshot) UseCaseSensitiveFileNames() bool { return s.fs.fs.UseCaseSensitiveFileNames() } func (s *Snapshot) ReadFile(fileName string) (string, bool) { handle := s.GetFile(fileName) if handle == nil { return "", false } return handle.Content(), true } func (s *Snapshot) DirectoryExists(path string) bool { return s.fs.fs.DirectoryExists(path) } func (s *Snapshot) FileExists(path string) bool { return s.fs.fs.FileExists(path) } func (s *Snapshot) GetDirectories(path string) []string { return s.fs.fs.GetAccessibleEntries(path).Directories } func (s *Snapshot) ReadDirectory(currentDir string, path string, extensions []string, excludes []string, includes []string, depth int) []string { return vfsmatch.ReadDirectory(s.fs.fs, currentDir, path, extensions, excludes, includes, depth) } type APISnapshotRequest struct { OpenProjects *collections.Set[string] CloseProjects *collections.Set[tspath.Path] OpenFiles *collections.Set[lsproto.DocumentUri] CloseFiles *collections.Set[tspath.Path] } type ProjectTreeRequest struct { // If null, all project trees need to be loaded, otherwise only those that are referenced referencedProjects *collections.Set[tspath.Path] } func (p *ProjectTreeRequest) IsAllProjects() bool { return p.referencedProjects == nil } func (p *ProjectTreeRequest) IsProjectReferenced(projectID tspath.Path) bool { return p.referencedProjects.Has(projectID) } func (p *ProjectTreeRequest) Projects() []tspath.Path { if p.referencedProjects == nil { return nil } return slices.Collect(maps.Keys(p.referencedProjects.Keys())) } type ResourceRequest struct { // Documents are URIs that were requested by the client. // The new snapshot should ensure projects for these URIs have loaded programs. Documents []lsproto.DocumentUri // ConfiguredProjectDocuments are URIs for which configured projects should be loaded // (if disableSolutionSearching/disableReferencedProjectLoad settings allow), // but no inferred project should be created if no configured project is found. // This is used by cross-project operations like find-all-references. ConfiguredProjectDocuments []lsproto.DocumentUri // Update requested Projects. // this is used when we want to get LS and from all the Projects the file can be part of Projects []tspath.Path // Update and ensure project trees that reference the projects // This is used to compute the solution and project tree so that // we can find references across all the projects in the solution irrespective of which project is open ProjectTree *ProjectTreeRequest // AutoImports is the document URI for which auto imports should be prepared. AutoImports lsproto.DocumentUri } type SnapshotChange struct { ResourceRequest reason UpdateReason // fileChanges are the changes that have occurred since the last snapshot. fileChanges FileChangeSummary // compilerOptionsForInferredProjects is the compiler options to use for inferred projects. // It should only be set the value in the next snapshot should be changed. If nil, the // value from the previous snapshot will be copied to the new snapshot. compilerOptionsForInferredProjects *core.CompilerOptions newConfig *lsutil.UserPreferences // ataChanges contains ATA-related changes to apply to projects in the new snapshot. ataChanges map[tspath.Path]*ATAStateChange apiRequest *APISnapshotRequest // cleanDiskCache triggers cleaning of cached disk files not referenced by any open project. cleanDiskCache bool } // ATAStateChange represents a change to a project's ATA state. type ATAStateChange struct { ProjectID tspath.Path // TypingsInfo is the new typings info for the project. TypingsInfo *ata.TypingsInfo // TypingsFiles is the new list of typing files for the project. TypingsFiles []string // TypingsFilesToWatch is the new list of typing files to watch for changes. TypingsFilesToWatch []string Logs *logging.LogTree } func (s *Snapshot) Clone(ctx context.Context, change SnapshotChange, overlays map[tspath.Path]*Overlay, session *Session) *Snapshot { var logger *logging.LogTree // Print in-progress logs immediately if cloning fails if session.options.LoggingEnabled { defer func() { if r := recover(); r != nil { session.logger.Log(logger.String()) panic(r) } }() } if session.options.LoggingEnabled { logger = logging.NewLogTree(fmt.Sprintf("Cloning snapshot %d", s.id)) getDetails := func() string { details := "" if len(change.Documents) != 0 { details += fmt.Sprintf(" Documents: %v", change.Documents) } if len(change.ConfiguredProjectDocuments) != 0 { details += fmt.Sprintf(" ConfiguredProjectDocuments: %v", change.ConfiguredProjectDocuments) } if len(change.Projects) != 0 { details += fmt.Sprintf(" Projects: %v", change.Projects) } if change.ProjectTree != nil { details += fmt.Sprintf(" ProjectTree: %v", change.ProjectTree.Projects()) } return details } switch change.reason { case UpdateReasonDidOpenFile: logger.Logf("Reason: DidOpenFile - %s", change.fileChanges.Opened) case UpdateReasonDidCloseFile: logger.Logf("Reason: DidCloseFile - %v", change.fileChanges.Closed) case UpdateReasonDidChangeCompilerOptionsForInferredProjects: logger.Logf("Reason: DidChangeCompilerOptionsForInferredProjects") case UpdateReasonRequestedLanguageServicePendingChanges: logger.Logf("Reason: RequestedLanguageService (pending file changes) - %v", getDetails()) case UpdateReasonRequestedLanguageServiceProjectNotLoaded: logger.Logf("Reason: RequestedLanguageService (project not loaded) - %v", getDetails()) case UpdateReasonRequestedLanguageServiceForFileNotOpen: logger.Logf("Reason: RequestedLanguageService (file not open) - %v", getDetails()) case UpdateReasonRequestedLanguageServiceProjectDirty: logger.Logf("Reason: RequestedLanguageService (project dirty) - %v", getDetails()) case UpdateReasonRequestedLoadProjectTree: logger.Logf("Reason: RequestedLoadProjectTree - %v", getDetails()) case UpdateReasonIdleCleanDiskCache: logger.Logf("Reason: IdleCleanDiskCache") } } start := time.Now() fs := newSnapshotFSBuilder(session.fs.fs, s.fs.overlays, overlays, s.fs.diskFiles, s.fs.diskDirectories, s.fs.nodeModulesRealpathAliases, session.options.PositionEncoding, s.toPath) if change.fileChanges.HasExcessiveWatchEvents() { invalidateStart := time.Now() if change.fileChanges.InvalidateAll { fs.invalidateCache() logger.Logf("InvalidateAll: invalidated file cache in %v", time.Since(invalidateStart)) } else if !fs.watchChangesOverlapCache(change.fileChanges) { // All watch changes/deletes are files we haven't seen; should be irrelevant to us (probably an external tool's build or something) change.fileChanges.Changed = collections.Set[lsproto.DocumentUri]{} change.fileChanges.Deleted = collections.Set[lsproto.DocumentUri]{} } else if change.fileChanges.IncludesWatchChangeOutsideNodeModules { fs.invalidateCache() logger.Logf("Excessive watch changes detected, invalidated file cache in %v", time.Since(invalidateStart)) } else { fs.invalidateNodeModulesCache() logger.Logf("npm install detected, invalidated node_modules cache in %v", time.Since(invalidateStart)) } } else { change.fileChanges = fs.expandAndFilterWatchEvents(change.fileChanges) change.fileChanges = s.fs.expandRealpathAliases(change.fileChanges) fs.markDirtyFiles(change.fileChanges) change.fileChanges = fs.convertOpenAndCloseToChanges(change.fileChanges) } compilerOptionsForInferredProjects := s.compilerOptionsForInferredProjects if change.compilerOptionsForInferredProjects != nil { // !!! mark inferred projects as dirty? compilerOptionsForInferredProjects = change.compilerOptionsForInferredProjects } // Compute effective customConfigFileName from user preferences customConfigFileName := s.ConfigFileRegistry.customConfigFileName if change.newConfig != nil { customConfigFileName = change.newConfig.CustomConfigFileName } newSnapshotID := session.snapshotID.Add(1) projectCollectionBuilder := newProjectCollectionBuilder( ctx, newSnapshotID, fs, s.ProjectCollection, s.ConfigFileRegistry, s.ProjectCollection.apiState, compilerOptionsForInferredProjects, s.sessionOptions, customConfigFileName, session.parseCache, session.extendedConfigCache, session.client, ) if len(change.ataChanges) != 0 { projectCollectionBuilder.DidUpdateATAState(change.ataChanges, logger.Fork("DidUpdateATAState")) } projectCollectionBuilder.DidChangeCustomConfigFileName(logger.Fork("DidChangeCustomConfigFileName")) if !change.fileChanges.IsEmpty() { projectCollectionBuilder.DidChangeFiles(change.fileChanges, logger.Fork("DidChangeFiles")) } var apiError error if change.apiRequest != nil { apiError = projectCollectionBuilder.HandleAPIRequest(change.apiRequest, logger.Fork("HandleAPIRequest")) } for _, uri := range change.Documents { projectCollectionBuilder.DidRequestFile(uri, false /*configuredProjectsOnly*/, logger.Fork("DidRequestFile")) } for _, uri := range change.ConfiguredProjectDocuments { projectCollectionBuilder.DidRequestFile(uri, true /*configuredProjectsOnly*/, logger.Fork("DidRequestFile (optional)")) } for _, projectId := range change.Projects { projectCollectionBuilder.DidRequestProject(projectId, logger.Fork("DidRequestProject")) } if change.ProjectTree != nil { projectCollectionBuilder.DidRequestProjectTrees(change.ProjectTree, logger.Fork("DidRequestProjectTrees")) } projectCollection, configFileRegistry := projectCollectionBuilder.Finalize(logger) projectsWithNewProgramStructure := make(map[tspath.Path]bool) for _, project := range projectCollection.Projects() { if project.ProgramLastUpdate == newSnapshotID && project.ProgramUpdateKind != ProgramUpdateKindCloned { projectsWithNewProgramStructure[project.configFilePath] = project.ProgramUpdateKind == ProgramUpdateKindNewFiles } } // Clean cached disk files not touched by any open project on file open, close, delete, // or when explicitly requested (e.g. by an idle timer). shouldCleanDiskCache := change.cleanDiskCache || change.fileChanges.Opened != "" || change.fileChanges.Reopened != "" || change.fileChanges.Closed.Len() > 0 || change.fileChanges.Deleted.Len() > 0 if shouldCleanDiskCache { // The set of seen files can change only if a program was constructed (not cloned) during this snapshot. // When cleanDiskCache is explicitly set, always attempt cleaning. if len(projectsWithNewProgramStructure) > 0 || change.cleanDiskCache { cleanFilesStart := time.Now() removedFiles := 0 fs.diskFiles.Range(func(entry *dirty.SyncMapEntry[tspath.Path, *diskFile]) bool { for _, project := range projectCollection.Projects() { if project.host != nil && project.host.sourceFS.SeenFile(entry.Key()) { return true } } entry.Delete() removedFiles++ return true }) if session.options.LoggingEnabled { logger.Logf("Removed %d cached file(s) in %v", removedFiles, time.Since(cleanFilesStart)) } } } config := s.userPreferences if change.newConfig != nil { config = *change.newConfig } autoImportHost := newAutoImportRegistryCloneHost( projectCollection, session.parseCache, fs, s.sessionOptions.CurrentDirectory, s.toPath, ) openFiles := make(map[tspath.Path]string, len(overlays)) for path, overlay := range overlays { openFiles[path] = overlay.FileName() } prepareAutoImports := tspath.Path("") if change.ResourceRequest.AutoImports != "" { prepareAutoImports = change.ResourceRequest.AutoImports.Path(s.UseCaseSensitiveFileNames()) } oldAutoImports := s.AutoImports if oldAutoImports == nil { oldAutoImports = autoimport.NewRegistry(s.toPath, s.userPreferences) } var autoImportsWatch *WatchedFiles[map[tspath.Path]string] autoImports, err := oldAutoImports.Clone(ctx, autoimport.RegistryChange{ RequestedFile: prepareAutoImports, OpenFiles: openFiles, Changed: change.fileChanges.Changed, Created: change.fileChanges.Created, Deleted: change.fileChanges.Deleted, RebuiltPrograms: projectsWithNewProgramStructure, UserPreferences: change.newConfig, }, autoImportHost, logger.Fork("UpdateAutoImports")) if err == nil { autoImportsWatch = s.autoImportsWatch.Clone(autoImports.NodeModulesDirectories()) } snapshotFS, _ := fs.Finalize() newSnapshot := NewSnapshot( newSnapshotID, snapshotFS, s.sessionOptions, nil, compilerOptionsForInferredProjects, config, autoImports, autoImportsWatch, s.toPath, ) newSnapshot.parentId = s.id newSnapshot.ProjectCollection = projectCollection newSnapshot.ConfigFileRegistry = configFileRegistry newSnapshot.builderLogs = logger newSnapshot.apiError = apiError for _, project := range newSnapshot.ProjectCollection.Projects() { if project.Program != nil { session.programCounter.Ref(project.Program) if project.ProgramLastUpdate == newSnapshotID { // If the program was updated during this clone, the project and its host are new // and still retain references to the builder. Freezing clears the builder reference // so it's GC'd and to ensure the project can't access any data not already in the // snapshot during use. This is pretty kludgy, but it's an artifact of Program design: // Program has a single host, which is expected to implement a full vfs.FS, among // other things. That host is *mostly* only used during program *construction*, but a // few methods may get exercised during program *use*. So, our compiler host is allowed // to access caches and perform mutating effects (like acquire referenced project // config files) during snapshot building, and then we call `freeze` to ensure those // mutations don't happen afterwards. In the future, we might improve things by // separating what it takes to build a program from what it takes to use a program, // and only pass the former into NewProgram instead of retaining it indefinitely. project.host.freeze(snapshotFS, newSnapshot.ConfigFileRegistry) } } } for _, config := range newSnapshot.ConfigFileRegistry.configs { if config.commandLine != nil && config.commandLine.ConfigFile != nil { for _, file := range config.commandLine.ConfigFile.ExtendedSourceFiles { session.extendedConfigCache.AddOwner(newSnapshot.toPath(file), newSnapshot.id) } } } autoImportHost.Dispose() logger.Logf("Finished cloning snapshot %d into snapshot %d in %v", s.id, newSnapshot.id, time.Since(start)) return newSnapshot } // ref increments the snapshot's reference count, preventing it from being // disposed until a corresponding Deref is called. The snapshot must still // be alive (refCount > 0) when ref is called. Only the project Session // should call ref(), and it should be done while holding session.snapshotMu. func (s *Snapshot) ref() { if s.refCount.Add(1) <= 1 { panic(fmt.Sprintf("snapshot %d: ref on disposed snapshot, parentId=%d", s.id, s.parentId)) } } // tryRef attempts to increment the snapshot's reference count. If the // snapshot is already disposed (refCount == 0), it returns false without // modifying the count. On success the caller must eventually call Deref. func (s *Snapshot) tryRef() bool { for { rc := s.refCount.Load() if rc <= 0 { return false } if s.refCount.CompareAndSwap(rc, rc+1) { return true } } } // Deref decrements the snapshot's reference count. When the count reaches // zero, the snapshot is disposed and its resources are released. func (s *Snapshot) Deref(session *Session) { rc := s.refCount.Add(-1) if rc < 0 { panic(fmt.Sprintf("snapshot %d: ref count below zero, parentId=%d", s.id, s.parentId)) } if rc == 0 { s.dispose(session) } } func (s *Snapshot) dispose(session *Session) { for _, project := range s.ProjectCollection.Projects() { if project.Program != nil && session.programCounter.Deref(project.Program) { // This program is no longer referenced by any snapshot. // Mark its checker pool as discarded so its idle-cleanup timer stops // keeping the pool alive, allowing the pool and any idle checkers it // still references to be reclaimed when the pool is garbage-collected. if project.checkerPool != nil { project.checkerPool.Discard() } for _, file := range project.Program.SourceFiles() { session.parseCache.Deref(NewParseCacheKey(file.ParseOptions(), file.Hash, file.ScriptKind)) } for _, file := range project.Program.DuplicateSourceFiles() { session.parseCache.Deref(NewParseCacheKey(file.ParseOptions, file.Hash, file.ScriptKind)) } } } for _, config := range s.ConfigFileRegistry.configs { if config.commandLine != nil { for _, file := range config.commandLine.ExtendedSourceFiles() { session.extendedConfigCache.Release(session.toPath(file), s.id) } } } }