Skip to content

Add cleanup code for projects and script infos #1007

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Jun 5, 2025
2 changes: 1 addition & 1 deletion internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ func (api *API) LoadProject(configFileName string) (*ProjectResponse, error) {
configFileName = api.toAbsoluteFileName(configFileName)
configFilePath := api.toPath(configFileName)
p := project.NewConfiguredProject(configFileName, configFilePath, api)
if err := p.LoadConfig(); err != nil {
if _, err := p.LoadConfig(); err != nil {
return nil, err
}
p.GetProgram()
Expand Down
2 changes: 1 addition & 1 deletion internal/project/ata.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
198 changes: 121 additions & 77 deletions internal/project/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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,
Expand Down Expand Up @@ -362,6 +364,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:
Expand All @@ -371,14 +383,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 {
Expand Down Expand Up @@ -430,6 +437,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()
Expand Down Expand Up @@ -473,12 +489,14 @@ func (p *Project) updateGraph() bool {
p.parsedCommandLine = tsoptions.ReloadFileNamesOfParsedCommandLine(p.parsedCommandLine, p.host.FS())
writeFileNames = p.setRootFiles(p.parsedCommandLine.FileNames())
p.programConfig = nil
p.pendingReload = PendingReloadNone
case PendingReloadFull:
if err := p.loadConfig(); err != nil {
var err error
writeFileNames, err = p.LoadConfig()
if err != nil {
panic(fmt.Sprintf("failed to reload config: %v", err))
}
}
p.pendingReload = PendingReloadNone
}

oldProgramReused := p.updateProgram()
Expand All @@ -496,6 +514,7 @@ func (p *Project) updateGraph() bool {
for _, oldSourceFile := range oldProgram.GetSourceFiles() {
if p.program.GetSourceFileByPath(oldSourceFile.Path()) == nil {
p.host.DocumentRegistry().ReleaseDocument(oldSourceFile, oldProgram.GetCompilerOptions())
p.detachScriptInfoIfNotInferredRoot(oldSourceFile.Path())
}
}
}
Expand Down Expand Up @@ -707,7 +726,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
}

Expand Down Expand Up @@ -737,6 +756,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
Expand Down Expand Up @@ -804,23 +829,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()

Expand All @@ -832,49 +847,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")
Comment on lines +854 to +858
Copy link
Preview

Copilot AI Jun 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The function panics if the script info is already a root; consider whether a graceful error handling strategy might be more appropriate for production usage.

Copilot uses AI. Check for mistakes.

}
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 {
func (p *Project) LoadConfig() (bool, 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)
Expand All @@ -901,52 +901,44 @@ func (p *Project) loadConfig() error {
p.parsedCommandLine = parsedCommandLine
p.compilerOptions = parsedCommandLine.CompilerOptions()
p.typeAcquisition = parsedCommandLine.TypeAcquisition()
p.setRootFiles(parsedCommandLine.FileNames())
return p.setRootFiles(parsedCommandLine.FileNames()), nil
} else {
p.compilerOptions = &core.CompilerOptions{}
p.typeAcquisition = nil
return fmt.Errorf("could not read file %q", p.configFileName)
return false, fmt.Errorf("could not read file %q", p.configFileName)
}
return nil
}

// setRootFiles returns true if the set of root files has changed.
func (p *Project) setRootFiles(rootFileNames []string) bool {
var hasChanged bool
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)
}
}
}
if hasChanged {
p.onFileAddedOrRemoved()
}
return hasChanged
}

Expand Down Expand Up @@ -994,21 +986,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 {}
Expand All @@ -1026,8 +1017,61 @@ 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.GetCompilerOptions())
// 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.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 {
Expand Down
Loading