Files
kjol/tools/tsgo/internal/project/projectcollectionbuilder.go
2026-07-09 16:50:43 -04:00

1260 lines
44 KiB
Go

package project
import (
"context"
"fmt"
"maps"
"slices"
"time"
"github.com/microsoft/typescript-go/internal/collections"
"github.com/microsoft/typescript-go/internal/compiler"
"github.com/microsoft/typescript-go/internal/core"
"github.com/microsoft/typescript-go/internal/diagnostics"
"github.com/microsoft/typescript-go/internal/lsp/lsproto"
"github.com/microsoft/typescript-go/internal/project/dirty"
"github.com/microsoft/typescript-go/internal/project/logging"
"github.com/microsoft/typescript-go/internal/tsoptions"
"github.com/microsoft/typescript-go/internal/tspath"
)
type projectLoadKind int
const (
// Project is not created or updated, only looked up in cache
projectLoadKindFind projectLoadKind = iota
// Project is created and then its graph is updated
projectLoadKindCreate
)
type ProjectCollectionBuilder struct {
sessionOptions *SessionOptions
parseCache *ParseCache
extendedConfigCache *ExtendedConfigCache
toPath func(fileName string) tspath.Path
ctx context.Context
fs *snapshotFSBuilder
base *ProjectCollection
compilerOptionsForInferredProjects *core.CompilerOptions
configFileRegistryBuilder *configFileRegistryBuilder
client Client // optional; used for project loading notifications
newSnapshotID uint64
programStructureChanged bool
defaultProjectsInvalidated bool
openFilesChanged bool
fileDefaultProjects map[tspath.Path]tspath.Path
configuredProjects *dirty.SyncMap[tspath.Path, *Project]
inferredProject *dirty.Box[*Project]
apiState APIState
}
func newProjectCollectionBuilder(
ctx context.Context,
newSnapshotID uint64,
fs *snapshotFSBuilder,
oldProjectCollection *ProjectCollection,
oldConfigFileRegistry *ConfigFileRegistry,
oldAPIState APIState,
compilerOptionsForInferredProjects *core.CompilerOptions,
sessionOptions *SessionOptions,
customConfigFileName string,
parseCache *ParseCache,
extendedConfigCache *ExtendedConfigCache,
client Client,
) *ProjectCollectionBuilder {
return &ProjectCollectionBuilder{
ctx: ctx,
fs: fs,
toPath: fs.toPath,
compilerOptionsForInferredProjects: compilerOptionsForInferredProjects,
sessionOptions: sessionOptions,
parseCache: parseCache,
extendedConfigCache: extendedConfigCache,
base: oldProjectCollection,
configFileRegistryBuilder: newConfigFileRegistryBuilder(lsproto.GetClientCapabilities(ctx).Workspace.DidChangeWatchedFiles.RelativePatternSupport, fs, oldConfigFileRegistry, extendedConfigCache, newSnapshotID, sessionOptions, customConfigFileName, nil),
newSnapshotID: newSnapshotID,
configuredProjects: dirty.NewSyncMap(oldProjectCollection.configuredProjects),
inferredProject: dirty.NewBox(oldProjectCollection.inferredProject),
apiState: oldAPIState.clone(),
client: client,
}
}
func (b *ProjectCollectionBuilder) Finalize(logger *logging.LogTree) (*ProjectCollection, *ConfigFileRegistry) {
var changed bool
newProjectCollection := b.base
ensureCloned := func() {
if !changed {
newProjectCollection = newProjectCollection.clone()
changed = true
}
}
if configuredProjects, configuredProjectsChanged := b.configuredProjects.Finalize(); configuredProjectsChanged {
ensureCloned()
newProjectCollection.configuredProjects = configuredProjects
}
if b.openFilesChanged {
ensureCloned()
newProjectCollection.openFiles = openFilePaths(b.fs.overlays)
}
if !maps.Equal(b.fileDefaultProjects, b.base.fileDefaultProjects) {
ensureCloned()
newProjectCollection.fileDefaultProjects = b.fileDefaultProjects
}
if newInferredProject, inferredProjectChanged := b.inferredProject.Finalize(); inferredProjectChanged {
ensureCloned()
newProjectCollection.inferredProject = newInferredProject
}
configFileRegistry := b.configFileRegistryBuilder.Finalize()
if configFileRegistry != b.base.configFileRegistry {
ensureCloned()
newProjectCollection.configFileRegistry = configFileRegistry
}
if !b.apiState.equals(b.base.apiState) {
ensureCloned()
newProjectCollection.apiState = b.apiState
}
return newProjectCollection, configFileRegistry
}
func (b *ProjectCollectionBuilder) forEachProject(fn func(entry dirty.Value[*Project]) bool) {
keepGoing := true
b.configuredProjects.Range(func(entry *dirty.SyncMapEntry[tspath.Path, *Project]) bool {
keepGoing = fn(entry)
return keepGoing
})
if !keepGoing {
return
}
if b.inferredProject.Value() != nil {
fn(b.inferredProject)
}
}
func (b *ProjectCollectionBuilder) HandleAPIRequest(apiRequest *APISnapshotRequest, logger *logging.LogTree) error {
var projectsToClose map[tspath.Path]struct{}
if apiRequest.CloseProjects != nil {
for projectPath := range apiRequest.CloseProjects.Keys() {
// Ref-counted close: only actually close the project once the last
// API client that opened it releases it.
if count := b.apiState.openProjects[projectPath]; count > 1 {
b.apiState.openProjects[projectPath] = count - 1
} else if count == 1 {
delete(b.apiState.openProjects, projectPath)
if projectsToClose == nil {
projectsToClose = make(map[tspath.Path]struct{})
}
projectsToClose[projectPath] = struct{}{}
}
}
}
if apiRequest.OpenProjects != nil {
for configFileName := range apiRequest.OpenProjects.Keys() {
configPath := b.toPath(configFileName)
if entry := b.findOrCreateProject(configFileName, configPath, projectLoadKindCreate, logger); entry != nil {
if b.apiState.openProjects == nil {
b.apiState.openProjects = make(map[tspath.Path]int)
}
b.apiState.openProjects[configPath]++
// A project re-opened in the same request shouldn't be closed.
delete(projectsToClose, configPath)
} else {
return fmt.Errorf("project not found for open: %s", configFileName)
}
}
}
if apiRequest.CloseFiles != nil {
for path := range apiRequest.CloseFiles.Keys() {
// Ref-counted close mirroring projects above.
if entry, ok := b.apiState.openFiles[path]; ok {
if entry.refCount > 1 {
entry.refCount--
b.apiState.openFiles[path] = entry
} else {
delete(b.apiState.openFiles, path)
}
}
}
}
if apiRequest.OpenFiles != nil {
for uri := range apiRequest.OpenFiles.Keys() {
fileName := uri.FileName()
path := b.toPath(fileName)
if b.apiState.openFiles == nil {
b.apiState.openFiles = make(map[tspath.Path]apiOpenedFile)
}
entry := b.apiState.openFiles[path]
entry.fileName = fileName
entry.refCount++
b.apiState.openFiles[path] = entry
}
}
for configPath := range b.apiState.openProjects {
if entry, ok := b.configuredProjects.Load(configPath); ok {
b.updateProgram(entry, logger)
} else {
return fmt.Errorf("project not found for update: %s", configPath)
}
}
for _, overlay := range b.fs.overlays {
if entry := b.findDefaultConfiguredProject(overlay.FileName(), b.toPath(overlay.FileName())); entry != nil {
delete(projectsToClose, entry.Value().configFilePath)
}
}
for projectPath := range projectsToClose {
if entry, ok := b.configuredProjects.Load(projectPath); ok {
b.deleteConfiguredProject(entry, logger)
}
}
// Ensure each API-opened file is placed like LSP's textDocument/didOpen: search
// up ancestor directories for a configured project that contains it, and only
// fall back to the inferred project if none is found. This also keeps already
// loaded configured projects up to date. Then run the same cleanup the LSP open
// path uses, so configured projects auto-loaded for files that are no longer open
// are torn down instead of leaking.
if apiRequest.OpenFiles != nil || apiRequest.CloseFiles != nil {
var retain collections.Set[tspath.Path]
for path, file := range b.apiState.openFiles {
if b.fs.isOpenFile(path) {
// Already an LSP overlay; its project membership is handled by the
// overlay pass in cleanupConfiguredProjects.
continue
}
result := b.ensureConfiguredProjectAndAncestorsForFile(file.fileName, path, logger)
retain.Union(&result.retain)
}
b.cleanupConfiguredProjects(&retain, logger)
if b.inferredProject.Value() != nil {
b.updateProgram(b.inferredProject, logger)
}
}
return nil
}
func (b *ProjectCollectionBuilder) DidChangeFiles(summary FileChangeSummary, logger *logging.LogTree) {
b.openFilesChanged = b.openFilesChanged || summary.Opened != "" || summary.Closed.Len() > 0
changedFiles := make([]tspath.Path, 0, summary.Changed.Len())
for uri := range summary.Changed.Keys() {
fileName := uri.FileName()
path := b.toPath(fileName)
changedFiles = append(changedFiles, path)
}
configChangeLogger := logger.Fork("Checking for changes affecting config files")
configChangeResult := b.configFileRegistryBuilder.DidChangeFiles(summary, configChangeLogger)
logChangeFileResult(configChangeResult, configChangeLogger)
b.programStructureChanged = b.markProjectsAffectedByConfigChanges(configChangeResult, logger)
b.forEachProject(func(entry dirty.Value[*Project]) bool {
// Only consider change/delete; creates are handled by the config file registry
if summary.HasExcessiveNonCreateWatchEvents() {
entry.Change(func(p *Project) {
p.dirty = true
p.dirtyFilePath = ""
if logger != nil {
logger.Logf("Marking project as dirty due to excessive watch changes: %s", p.configFilePath)
}
})
return true
}
// Handle closed and changed files
b.markFilesChanged(entry, changedFiles, lsproto.FileChangeTypeChanged, logger)
if entry.Value().Kind == KindInferred && summary.Closed.Len() > 0 {
rootFilesMap := entry.Value().CommandLine.FileNamesByPath()
newRootFiles := entry.Value().CommandLine.FileNames()
for uri := range summary.Closed.Keys() {
fileName := uri.FileName()
path := b.toPath(fileName)
if _, ok := rootFilesMap[path]; ok {
newRootFiles = slices.Delete(newRootFiles, slices.Index(newRootFiles, fileName), slices.Index(newRootFiles, fileName)+1)
}
}
b.updateInferredProjectRoots(newRootFiles, logger)
}
// Handle deleted files
if summary.Deleted.Len() > 0 {
deletedPaths := make([]tspath.Path, 0, summary.Deleted.Len())
for uri := range summary.Deleted.Keys() {
fileName := uri.FileName()
path := b.toPath(fileName)
deletedPaths = append(deletedPaths, path)
}
b.markFilesChanged(entry, deletedPaths, lsproto.FileChangeTypeDeleted, logger)
}
// Handle created files
if summary.Created.Len() > 0 {
createdPaths := make([]tspath.Path, 0, summary.Created.Len())
for uri := range summary.Created.Keys() {
fileName := uri.FileName()
path := b.toPath(fileName)
createdPaths = append(createdPaths, path)
}
b.markFilesChanged(entry, createdPaths, lsproto.FileChangeTypeCreated, logger)
}
return true
})
// Handle opened file
if summary.Opened != "" || summary.Reopened != "" {
fileName := core.FirstNonZero(summary.Opened, summary.Reopened).FileName()
path := b.toPath(fileName)
openFileResult := b.ensureConfiguredProjectAndAncestorsForFile(fileName, path, logger)
b.cleanupConfiguredProjects(&openFileResult.retain, logger)
}
}
// cleanupConfiguredProjects sweeps the loaded configured projects and unloads those
// that are no longer needed. Starting from the set of all configured projects, it
// retains any project that is the default project (along with its references and
// ancestor configs) of an open overlay file or an API-opened file, any project
// explicitly opened through the API, and any project in retain (e.g. the ancestor
// solution tree built for a freshly opened overlay file). Every other configured
// project is deleted, the inferred project roots are recomputed, and the config file
// registry is cleaned up. This is the shared mechanism that keeps the set of loaded
// projects minimal for both LSP file opens and API file opens/closes.
func (b *ProjectCollectionBuilder) cleanupConfiguredProjects(retain *collections.Set[tspath.Path], logger *logging.LogTree) {
var toRemoveProjects collections.Set[tspath.Path]
b.configuredProjects.Range(func(entry *dirty.SyncMapEntry[tspath.Path, *Project]) bool {
toRemoveProjects.Add(entry.Key())
return true
})
retainProjectAndReferences := func(project *Project) {
// Retain project
toRemoveProjects.Delete(project.configFilePath)
if program := project.GetProgram(); program != nil {
program.RangeResolvedProjectReference(func(referencePath tspath.Path, _ *tsoptions.ParsedCommandLine, _ *tsoptions.ParsedCommandLine, _ int) bool {
if _, ok := b.configuredProjects.Load(referencePath); ok {
toRemoveProjects.Delete(referencePath)
}
return true
})
}
}
retainDefaultConfiguredProject := func(openFilePath tspath.Path, project *Project) {
// Retain project and its references
retainProjectAndReferences(project)
// Retain all the ancestor projects
b.configFileRegistryBuilder.forEachConfigFileNameFor(openFilePath, func(configFileName string) {
if ancestor := b.findOrCreateProject(configFileName, b.toPath(configFileName), projectLoadKindFind, logger); ancestor != nil {
retainProjectAndReferences(ancestor.Value())
}
})
}
var inferredProjectFiles []string
for _, overlay := range b.fs.overlays {
openFile := overlay.FileName()
openFilePath := b.toPath(openFile)
if p := b.findDefaultConfiguredProject(openFile, openFilePath); p != nil {
retainDefaultConfiguredProject(openFilePath, p.Value())
} else {
inferredProjectFiles = append(inferredProjectFiles, openFile)
}
}
// Treat API-opened files like open files: retain their configured project (so
// an LSP-driven open doesn't close it), or keep them as inferred project roots.
for path, file := range b.apiState.openFiles {
if b.fs.isOpenFile(path) {
continue
}
if p := b.findDefaultConfiguredProject(file.fileName, path); p != nil {
retainDefaultConfiguredProject(path, p.Value())
} else {
inferredProjectFiles = append(inferredProjectFiles, file.fileName)
}
}
for projectPath := range toRemoveProjects.Keys() {
if retain.Has(projectPath) {
continue
}
if _, ok := b.apiState.openProjects[projectPath]; ok {
continue
}
if p, ok := b.configuredProjects.Load(projectPath); ok {
b.deleteConfiguredProject(p, logger)
}
}
b.updateInferredProjectRoots(inferredProjectFiles, logger)
b.configFileRegistryBuilder.Cleanup()
}
func logChangeFileResult(result changeFileResult, logger *logging.LogTree) {
if len(result.affectedProjects) > 0 {
logger.Logf("Config file change affected projects: %v", slices.Collect(maps.Keys(result.affectedProjects)))
}
if len(result.affectedFiles) > 0 {
logger.Logf("Config file change affected config file lookups for %d files", len(result.affectedFiles))
}
}
func (b *ProjectCollectionBuilder) collectInferredProjectRoots() []string {
var inferredProjectFiles []string
for path, overlay := range b.fs.overlays {
if b.findDefaultConfiguredProject(overlay.FileName(), path) == nil {
inferredProjectFiles = append(inferredProjectFiles, overlay.FileName())
}
}
return b.appendAPIOpenedInferredRoots(inferredProjectFiles)
}
// appendAPIOpenedInferredRoots appends API-opened files that aren't open in an
// overlay and have no configured project, so they're kept as inferred project
// roots and persist across snapshots.
func (b *ProjectCollectionBuilder) appendAPIOpenedInferredRoots(inferredProjectFiles []string) []string {
for path, file := range b.apiState.openFiles {
if b.fs.isOpenFile(path) {
continue
}
if b.findDefaultConfiguredProject(file.fileName, path) == nil {
inferredProjectFiles = append(inferredProjectFiles, file.fileName)
}
}
return inferredProjectFiles
}
func (b *ProjectCollectionBuilder) cleanupInferredProject(logger *logging.LogTree) {
b.updateInferredProjectRoots(b.collectInferredProjectRoots(), logger)
}
func (b *ProjectCollectionBuilder) ensureInferredProjectIncludesClosedFile(fileName string, logger *logging.LogTree) {
// Collect existing inferred project roots (open files not in configured projects)
// plus this closed file.
inferredProjectFiles := append(b.collectInferredProjectRoots(), fileName)
b.updateInferredProjectRoots(inferredProjectFiles, logger)
if b.inferredProject.Value() != nil {
b.updateProgram(b.inferredProject, logger)
}
}
// DidRequestFile ensures projects are loaded for the given URI.
// If configuredProjectsOnly is true, only configured projects are loaded; no inferred project is created
// and it is not guaranteed that there will be any project containing the file in the resulting snapshot.
func (b *ProjectCollectionBuilder) DidRequestFile(uri lsproto.DocumentUri, configuredProjectsOnly bool, logger *logging.LogTree) {
startTime := time.Now()
fileName := uri.FileName()
path := b.toPath(fileName)
if b.defaultProjectsInvalidated {
b.ensureConfiguredProjectAndAncestorsForFile(fileName, path, logger)
if !b.fs.isOpenFile(path) {
return
}
}
if b.fs.isOpenFile(path) {
hasChanges := b.programStructureChanged
// See if we can find a default project without updating a bunch of stuff.
if result := b.findDefaultProject(fileName, path); result != nil {
hasChanges = b.updateProgram(result, logger) || hasChanges
if result.Value() != nil {
if hasChanges {
b.cleanupInferredProject(logger)
if b.inferredProject.Value() != nil {
b.updateProgram(b.inferredProject, logger)
}
}
return
}
}
// Make sure all projects we know about are up to date...
b.configuredProjects.Range(func(entry *dirty.SyncMapEntry[tspath.Path, *Project]) bool {
hasChanges = b.updateProgram(entry, logger) || hasChanges
return true
})
if hasChanges {
// If the structure of other projects changed, we might need to move files
// in/out of the inferred project.
b.cleanupInferredProject(logger)
}
if b.inferredProject.Value() != nil {
b.updateProgram(b.inferredProject, logger)
}
// At this point we should be able to find the default project for the file without
// creating anything else. Initially, I verified that and panicked if nothing was found,
// but that panic was getting triggered by fourslash infrastructure when it told us to
// open a package.json file. This is something the VS Code client would never do, but
// it seems possible that another client would. There's no point in panicking; we don't
// really even have an error condition until it tries to ask us language questions about
// a non-TS-handleable file.
} else {
result := b.ensureConfiguredProjectAndAncestorsForFile(fileName, path, logger)
if result.project == nil && !configuredProjectsOnly {
// No configured project found for this closed file.
// Add it to the inferred project so language service requests can be served.
b.ensureInferredProjectIncludesClosedFile(fileName, logger)
}
}
if logger != nil {
elapsed := time.Since(startTime)
logger.Log(fmt.Sprintf("Completed file request for %s in %v", fileName, elapsed))
}
}
func (b *ProjectCollectionBuilder) DidRequestProject(projectId tspath.Path, logger *logging.LogTree) {
startTime := time.Now()
if projectId == inferredProjectName {
// Update inferred project
if b.inferredProject.Value() != nil {
b.updateProgram(b.inferredProject, logger)
}
} else {
if entry, ok := b.configuredProjects.Load(projectId); ok {
b.updateProgram(entry, logger)
}
}
if logger != nil {
elapsed := time.Since(startTime)
logger.Log(fmt.Sprintf("Completed project update request for %s in %v", projectId, elapsed))
}
}
func (b *ProjectCollectionBuilder) DidRequestProjectTrees(projectTreeRequest *ProjectTreeRequest, logger *logging.LogTree) {
startTime := time.Now()
var currentProjects []tspath.Path
b.configuredProjects.Range(func(sme *dirty.SyncMapEntry[tspath.Path, *Project]) bool {
currentProjects = append(currentProjects, sme.Key())
return true
})
var seenProjects collections.SyncSet[tspath.Path]
wg := core.NewWorkGroup(false)
for _, projectId := range currentProjects {
wg.Queue(func() {
if entry, ok := b.configuredProjects.Load(projectId); ok {
// If this project has potential project reference for any of the project we are loading ancestor tree for
// load this project first
if project := entry.Value(); project != nil && (projectTreeRequest.IsAllProjects() || project.hasPotentialProjectReference(projectTreeRequest)) {
b.updateProgram(entry, logger)
}
b.ensureProjectTree(wg, entry, projectTreeRequest, &seenProjects, logger)
}
})
}
wg.RunAndWait()
if logger != nil {
elapsed := time.Since(startTime)
logger.Log(fmt.Sprintf("Completed project tree request for %v in %v", projectTreeRequest.Projects(), elapsed))
}
}
func (b *ProjectCollectionBuilder) ensureProjectTree(
wg core.WorkGroup,
entry *dirty.SyncMapEntry[tspath.Path, *Project],
projectTreeRequest *ProjectTreeRequest,
seenProjects *collections.SyncSet[tspath.Path],
logger *logging.LogTree,
) {
if !seenProjects.AddIfAbsent(entry.Key()) {
return
}
project := entry.Value()
if project == nil {
return
}
program := project.GetProgram()
if program == nil {
return
}
// If this project disables child load ignore it
if program.CommandLine().CompilerOptions().DisableReferencedProjectLoad.IsTrue() {
return
}
children := program.GetResolvedProjectReferences()
if children == nil {
return
}
for _, childConfig := range children {
if childConfig == nil {
continue
}
wg.Queue(func() {
if !projectTreeRequest.IsAllProjects() && program.RangeResolvedProjectReferenceInChildConfig(
childConfig,
func(referencePath tspath.Path, config *tsoptions.ParsedCommandLine, _ *tsoptions.ParsedCommandLine, _ int) bool {
return !projectTreeRequest.IsProjectReferenced(referencePath)
},
) {
return
}
// Load this child project since this is referenced
childProjectEntry := b.findOrCreateProject(childConfig.ConfigName(), childConfig.ConfigFile.SourceFile.Path(), projectLoadKindCreate, logger)
b.updateProgram(childProjectEntry, logger)
// Ensure children for this project
b.ensureProjectTree(wg, childProjectEntry, projectTreeRequest, seenProjects, logger)
})
}
}
func (b *ProjectCollectionBuilder) DidUpdateATAState(ataChanges map[tspath.Path]*ATAStateChange, logger *logging.LogTree) {
updateProject := func(project dirty.Value[*Project], ataChange *ATAStateChange) {
project.ChangeIf(
func(p *Project) bool {
if p == nil {
return false
}
// Consistency check: the ATA demands (project options, unresolved imports) of this project
// has not changed since the time the ATA request was dispatched; the change can still be
// applied to this project in its current state.
return ataChange.TypingsInfo.Equals(p.ComputeTypingsInfo())
},
func(p *Project) {
// We checked before triggering this change (in Session.triggerATAForUpdatedProjects) that
// the set of typings files is actually different.
p.installedTypingsInfo = ataChange.TypingsInfo
p.typingsFiles = ataChange.TypingsFiles
typingsWatchGlobs := getTypingsLocationsGlobs(
ataChange.TypingsFilesToWatch,
b.sessionOptions.TypingsLocation,
b.sessionOptions.CurrentDirectory,
p.currentDirectory,
b.fs.fs.UseCaseSensitiveFileNames(),
)
p.typingsWatch = p.typingsWatch.Clone(typingsWatchGlobs)
p.dirty = true
p.dirtyFilePath = ""
},
)
}
for projectPath, ataChange := range ataChanges {
logger.Embed(ataChange.Logs)
if projectPath == inferredProjectName {
updateProject(b.inferredProject, ataChange)
} else if project, ok := b.configuredProjects.Load(projectPath); ok {
updateProject(project, ataChange)
}
if logger != nil {
logger.Log(fmt.Sprintf("Updated ATA state for project %s", projectPath))
}
}
}
// if customConfigFileName changes, invalidate default projects.
func (b *ProjectCollectionBuilder) DidChangeCustomConfigFileName(logger *logging.LogTree) {
if !b.configFileRegistryBuilder.DidChangeCustomConfigFileName(logger) {
return
}
b.fileDefaultProjects = nil
b.defaultProjectsInvalidated = true
b.programStructureChanged = true
}
func (b *ProjectCollectionBuilder) markProjectsAffectedByConfigChanges(
configChangeResult changeFileResult,
logger *logging.LogTree,
) bool {
for projectPath := range configChangeResult.affectedProjects {
project, ok := b.configuredProjects.Load(projectPath)
if !ok {
panic(fmt.Sprintf("project %s affected by config change not found", projectPath))
}
project.ChangeIf(
func(p *Project) bool { return !p.dirty || p.dirtyFilePath != "" },
func(p *Project) {
p.dirty = true
p.dirtyFilePath = ""
if logger != nil {
logger.Logf("Marking project %s as dirty due to change affecting config", projectPath)
}
},
)
}
// Recompute default projects for open files that now have different config file presence.
var hasChanges bool
for path := range configChangeResult.affectedFiles {
fileName := b.fs.overlays[path].FileName()
_ = b.ensureConfiguredProjectAndAncestorsForFile(fileName, path, logger)
hasChanges = true
}
return hasChanges
}
func (b *ProjectCollectionBuilder) findDefaultProject(fileName string, path tspath.Path) dirty.Value[*Project] {
if configuredProject := b.findDefaultConfiguredProject(fileName, path); configuredProject != nil {
return configuredProject
}
if key, ok := b.fileDefaultProjects[path]; ok && key == inferredProjectName {
return b.inferredProject
}
if inferredProject := b.inferredProject.Value(); inferredProject != nil && inferredProject.containsFile(path) {
if b.fileDefaultProjects == nil {
b.fileDefaultProjects = make(map[tspath.Path]tspath.Path)
}
b.fileDefaultProjects[path] = inferredProjectName
return b.inferredProject
}
return nil
}
func (b *ProjectCollectionBuilder) findDefaultConfiguredProject(fileName string, path tspath.Path) *dirty.SyncMapEntry[tspath.Path, *Project] {
if key, ok := b.fileDefaultProjects[path]; ok && key != inferredProjectName {
if entry, ok := b.configuredProjects.Load(key); ok {
return entry
}
}
// Sort configured projects so we can use a deterministic "first" as a last resort.
var configuredProjectPaths []tspath.Path
configuredProjects := make(map[tspath.Path]*dirty.SyncMapEntry[tspath.Path, *Project])
b.configuredProjects.Range(func(entry *dirty.SyncMapEntry[tspath.Path, *Project]) bool {
configuredProjectPaths = append(configuredProjectPaths, entry.Key())
configuredProjects[entry.Key()] = entry
return true
})
slices.Sort(configuredProjectPaths)
project, multipleCandidates := findDefaultConfiguredProjectFromProgramInclusion(fileName, path, configuredProjectPaths, func(path tspath.Path) *Project {
return configuredProjects[path].Value()
})
if multipleCandidates {
if p := b.findOrCreateDefaultConfiguredProjectForFile(fileName, path, projectLoadKindFind, nil).project; p != nil {
return p
}
}
return configuredProjects[project]
}
func (b *ProjectCollectionBuilder) ensureConfiguredProjectAndAncestorsForFile(fileName string, path tspath.Path, logger *logging.LogTree) searchResult {
result := b.findOrCreateDefaultConfiguredProjectForFile(fileName, path, projectLoadKindCreate, logger)
if result.project != nil && b.fs.isOpenFile(path) {
b.createAncestorTree(fileName, path, &result, logger)
}
return result
}
func (b *ProjectCollectionBuilder) createAncestorTree(fileName string, path tspath.Path, openResult *searchResult, logger *logging.LogTree) {
project := openResult.project.Value()
for {
// Skip if project is not composite and we are only looking for solution
if project.CommandLine != nil &&
(!project.CommandLine.CompilerOptions().Composite.IsTrue() ||
project.CommandLine.CompilerOptions().DisableSolutionSearching.IsTrue()) {
return
}
// Get config file name
ancestorConfigName := b.configFileRegistryBuilder.getAncestorConfigFileName(fileName, path, project.configFileName, logger)
if ancestorConfigName == "" {
return
}
// find or delay load the project
ancestorPath := b.toPath(ancestorConfigName)
ancestor := b.findOrCreateProject(ancestorConfigName, ancestorPath, projectLoadKindCreate, logger)
if ancestor == nil {
return
}
openResult.retain.Add(ancestorPath)
// If this ancestor is new and was not updated because we are just creating it for future loading
// eg when invoking find all references or rename that could span multiple projects
// we would make the current project as its potential project reference
if ancestor.Value().CommandLine == nil &&
(project.CommandLine == nil || project.CommandLine.CompilerOptions().Composite.IsTrue()) {
ancestor.Change(func(ancestorProject *Project) {
ancestorProject.setPotentialProjectReference(project.configFilePath)
})
}
project = ancestor.Value()
}
}
type searchNode struct {
configFileName string
loadKind projectLoadKind
logger *logging.LogTree
}
type searchNodeKey struct {
configFileName string
loadKind projectLoadKind
}
type searchResult struct {
project *dirty.SyncMapEntry[tspath.Path, *Project]
retain collections.Set[tspath.Path]
}
func (b *ProjectCollectionBuilder) findOrCreateDefaultConfiguredProjectWorker(
fileName string,
path tspath.Path,
configFileName string,
loadKind projectLoadKind,
visited *collections.SyncSet[searchNodeKey],
fallback *searchResult,
logger *logging.LogTree,
) searchResult {
var configs collections.SyncMap[tspath.Path, *tsoptions.ParsedCommandLine]
if visited == nil {
visited = &collections.SyncSet[searchNodeKey]{}
}
search := core.BreadthFirstSearchParallelEx(
searchNode{configFileName: configFileName, loadKind: loadKind, logger: logger},
func(node searchNode) []searchNode {
if config, ok := configs.Load(b.toPath(node.configFileName)); ok && len(config.ProjectReferences()) > 0 {
referenceLoadKind := node.loadKind
if config.CompilerOptions().DisableReferencedProjectLoad.IsTrue() {
referenceLoadKind = projectLoadKindFind
}
var refLogger *logging.LogTree
references := config.ResolvedProjectReferencePaths()
if len(references) > 0 && node.logger != nil {
refLogger = node.logger.Fork(fmt.Sprintf("Searching %d project references of %s", len(references), node.configFileName))
}
return core.Map(references, func(configFileName string) searchNode {
return searchNode{configFileName: configFileName, loadKind: referenceLoadKind, logger: refLogger.Fork("Searching project reference " + configFileName)}
})
}
return nil
},
func(node searchNode) (isResult bool, stop bool) {
configFilePath := b.toPath(node.configFileName)
config := b.configFileRegistryBuilder.findOrAcquireConfigForFile(node.configFileName, configFilePath, path, node.loadKind, node.logger.Fork("Acquiring config for open file"))
if config == nil {
node.logger.Log("Config file for project does not already exist")
return false, false
}
configs.Store(configFilePath, config)
if len(config.FileNames()) == 0 {
// Likely a solution tsconfig.json - the search will fan out to its references.
node.logger.Log("Project does not contain file (no root files)")
return false, false
}
if config.CompilerOptions().Composite == core.TSTrue {
// For composite projects, we can get an early negative result.
// !!! what about declaration files in node_modules? wouldn't it be better to
// check project inclusion if the project is already loaded?
if _, ok := config.FileNamesByPath()[path]; !ok {
node.logger.Log("Project does not contain file (by composite config inclusion)")
return false, false
}
}
project := b.findOrCreateProject(node.configFileName, configFilePath, node.loadKind, node.logger)
if project == nil {
node.logger.Log("Project does not already exist")
return false, false
}
if node.loadKind == projectLoadKindCreate {
// Ensure project is up to date before checking for file inclusion
b.updateProgram(project, node.logger)
}
if project.Value().containsFile(path) {
isDirectInclusion := !project.Value().IsSourceFromProjectReference(path)
if node.logger != nil {
node.logger.Logf("Project contains file %s", core.IfElse(isDirectInclusion, "directly", "as a source of a referenced project"))
}
return true, isDirectInclusion
}
node.logger.Log("Project does not contain file")
return false, false
},
core.BreadthFirstSearchOptions[searchNodeKey, searchNode]{
Visited: visited,
PreprocessLevel: func(level *core.BreadthFirstSearchLevel[searchNodeKey, searchNode]) {
level.Range(func(node searchNode) bool {
if node.loadKind == projectLoadKindFind && level.Has(searchNodeKey{configFileName: node.configFileName, loadKind: projectLoadKindCreate}) {
// Remove find requests when a create request for the same project is already present.
level.Delete(searchNodeKey{configFileName: node.configFileName, loadKind: node.loadKind})
}
return true
})
},
},
func(node searchNode) searchNodeKey {
return searchNodeKey{configFileName: node.configFileName, loadKind: node.loadKind}
},
)
var retain collections.Set[tspath.Path]
var project *dirty.SyncMapEntry[tspath.Path, *Project]
if len(search.Path) > 0 {
project, _ = b.configuredProjects.Load(b.toPath(search.Path[0].configFileName))
// If we found a project, we retain each project along the BFS path.
// We don't want to retain everything we visited since BFS can terminate
// early, and we don't want to retain nondeterministically.
for _, node := range search.Path {
retain.Add(b.toPath(node.configFileName))
}
}
if search.Stopped {
// Found a project that directly contains the file.
return searchResult{
project: project,
retain: retain,
}
}
if project != nil {
// If we found a project that contains the file, but it is a source from
// a project reference, record it as a fallback.
fallback = &searchResult{
project: project,
retain: retain,
}
}
// Look for tsconfig.json files higher up the directory tree and do the same. This handles
// the common case where a higher-level "solution" tsconfig.json contains all projects in a
// workspace.
if config, ok := configs.Load(b.toPath(configFileName)); ok && config.CompilerOptions().DisableSolutionSearching.IsTrue() {
if fallback != nil {
return *fallback
}
}
if ancestorConfigName := b.configFileRegistryBuilder.getAncestorConfigFileName(fileName, path, configFileName, logger); ancestorConfigName != "" {
return b.findOrCreateDefaultConfiguredProjectWorker(
fileName,
path,
ancestorConfigName,
loadKind,
visited,
fallback,
logger.Fork("Searching ancestor config file at "+ancestorConfigName),
)
}
if fallback != nil {
return *fallback
}
// If we didn't find anything, we can retain everything we visited,
// since the whole graph must have been traversed (i.e., the set of
// retained projects is guaranteed to be deterministic).
visited.Range(func(node searchNodeKey) bool {
retain.Add(b.toPath(node.configFileName))
return true
})
return searchResult{retain: retain}
}
func (b *ProjectCollectionBuilder) findOrCreateDefaultConfiguredProjectForFile(
fileName string,
path tspath.Path,
loadKind projectLoadKind,
logger *logging.LogTree,
) searchResult {
if key, ok := b.fileDefaultProjects[path]; ok {
if key == inferredProjectName {
// The file belongs to the inferred project
return searchResult{}
}
entry, _ := b.configuredProjects.Load(key)
return searchResult{project: entry}
}
if configFileName := b.configFileRegistryBuilder.getConfigFileNameForFile(fileName, path, logger); configFileName != "" {
startTime := time.Now()
result := b.findOrCreateDefaultConfiguredProjectWorker(
fileName,
path,
configFileName,
loadKind,
nil,
nil,
logger.Fork("Searching for default configured project for "+fileName),
)
if result.project != nil {
if b.fileDefaultProjects == nil {
b.fileDefaultProjects = make(map[tspath.Path]tspath.Path)
}
b.fileDefaultProjects[path] = result.project.Value().configFilePath
}
if logger != nil {
elapsed := time.Since(startTime)
if result.project != nil {
logger.Log(fmt.Sprintf("Found default configured project for %s: %s (in %v)", fileName, result.project.Value().configFileName, elapsed))
} else {
logger.Log(fmt.Sprintf("No default configured project found for %s (searched in %v)", fileName, elapsed))
}
}
return result
}
return searchResult{}
}
func (b *ProjectCollectionBuilder) findOrCreateProject(
configFileName string,
configFilePath tspath.Path,
loadKind projectLoadKind,
logger *logging.LogTree,
) *dirty.SyncMapEntry[tspath.Path, *Project] {
if loadKind == projectLoadKindFind {
entry, _ := b.configuredProjects.Load(configFilePath)
return entry
}
entry, _ := b.configuredProjects.LoadOrStore(configFilePath, NewConfiguredProject(configFileName, configFilePath, b, logger))
return entry
}
func (b *ProjectCollectionBuilder) updateInferredProjectRoots(rootFileNames []string, logger *logging.LogTree) bool {
if len(rootFileNames) == 0 {
if b.inferredProject.Value() != nil {
if logger != nil {
logger.Log("Deleting inferred project")
}
b.inferredProject.Delete()
return true
}
return false
}
slices.Sort(rootFileNames)
if b.inferredProject.Value() == nil {
b.inferredProject.Set(NewInferredProject(b.sessionOptions.CurrentDirectory, b.compilerOptionsForInferredProjects, rootFileNames, b, logger))
} else {
newCompilerOptions := b.inferredProject.Value().CommandLine.CompilerOptions()
if b.compilerOptionsForInferredProjects != nil {
newCompilerOptions = b.compilerOptionsForInferredProjects
}
newCommandLine := tsoptions.NewParsedCommandLine(newCompilerOptions, rootFileNames, tspath.ComparePathsOptions{
UseCaseSensitiveFileNames: b.fs.fs.UseCaseSensitiveFileNames(),
CurrentDirectory: b.sessionOptions.CurrentDirectory,
})
changed := b.inferredProject.ChangeIf(
func(p *Project) bool {
return !maps.Equal(p.CommandLine.FileNamesByPath(), newCommandLine.FileNamesByPath())
},
func(p *Project) {
if logger != nil {
logger.Log(fmt.Sprintf("Updating inferred project config with %d root files", len(rootFileNames)))
}
p.SetCommandLine(newCommandLine)
},
)
if !changed {
return false
}
}
return true
}
// updateProgram updates the program for the given project entry if necessary. It returns
// a boolean indicating whether the update could have caused any structure-affecting changes.
func (b *ProjectCollectionBuilder) updateProgram(entry dirty.Value[*Project], logger *logging.LogTree) bool {
var updateProgram bool
var deleteProject bool
var filesChanged bool
configFileName := entry.Value().configFileName
startTime := time.Now()
var notifiedLoading bool
var displayName string
entry.Locked(func(entry dirty.Value[*Project]) {
if entry.Value().Kind == KindConfigured {
commandLine := b.configFileRegistryBuilder.acquireConfigForProject(
entry.Value().configFileName,
entry.Value().configFilePath,
entry.Value(),
logger.Fork("Acquiring config for project"),
)
if commandLine == nil {
deleteProject = true
filesChanged = true
return
}
if entry.Value().CommandLine != commandLine {
updateProgram = true
entry.Change(func(p *Project) {
p.SetCommandLine(commandLine)
})
}
}
if !updateProgram {
updateProgram = entry.Value().dirty
}
if updateProgram {
if b.client != nil {
displayName = entry.Value().DisplayName(b.sessionOptions.CurrentDirectory)
notifiedLoading = true
}
}
})
if notifiedLoading && b.client != nil {
b.client.ProgressStart(diagnostics.Project_0, displayName)
}
if deleteProject {
b.deleteConfiguredProject(entry, logger)
}
if updateProgram {
entry.Locked(func(entry dirty.Value[*Project]) {
entry.Change(func(project *Project) {
oldHost := project.host
oldProgram := project.Program
oldCheckerPool := project.checkerPool
project.host = newCompilerHost(project.currentDirectory, project, b, logger.Fork("CompilerHost"))
result := project.CreateProgram()
project.Program = result.Program
project.checkerPool = result.Program.GetCheckerPool().(*checkerPool)
project.ProgramUpdateKind = result.UpdateKind
project.ProgramLastUpdate = b.newSnapshotID
if result.UpdateKind == ProgramUpdateKindCloned {
project.host.sourceFS.seenFiles = oldHost.sourceFS.seenFiles
}
if result.UpdateKind == ProgramUpdateKindNewFiles {
filesChanged = true
project.programFilesWatch = project.CloneWatchers()
}
project.dirty = false
project.dirtyFilePath = ""
b.releaseDroppedProjectReferences(oldProgram, result.Program, project.configFilePath)
if oldCheckerPool != nil {
oldCheckerPool.Discard()
}
})
})
}
if notifiedLoading && b.client != nil {
b.client.ProgressFinish(diagnostics.Project_0, displayName)
}
if updateProgram && logger != nil {
elapsed := time.Since(startTime)
logger.Log(fmt.Sprintf("Program update for %s completed in %v", configFileName, elapsed))
}
return filesChanged
}
func (b *ProjectCollectionBuilder) markFilesChanged(entry dirty.Value[*Project], paths []tspath.Path, changeType lsproto.FileChangeType, logger *logging.LogTree) {
var dirty bool
var dirtyFilePath tspath.Path
entry.ChangeIf(
func(p *Project) bool {
if p.Program == nil || p.dirty && p.dirtyFilePath == "" {
return false
}
dirtyFilePath = p.dirtyFilePath
for _, path := range paths {
if p.containsFile(path) {
dirty = true
if changeType == lsproto.FileChangeTypeDeleted {
dirtyFilePath = ""
break
}
// package.json changes can affect module resolution and package
// identity (e.g. dedup decisions), so they must always trigger
// a full rebuild rather than a single-file clone.
if tspath.GetBaseFileName(string(path)) == "package.json" {
dirtyFilePath = ""
break
}
if dirtyFilePath == "" {
dirtyFilePath = path
} else if dirtyFilePath != path {
dirtyFilePath = ""
break
}
} else if p.host != nil &&
(changeType == lsproto.FileChangeTypeCreated && p.host.sourceFS.SeenFileOrMissingParentDirectory(path) ||
changeType != lsproto.FileChangeTypeCreated && p.host.sourceFS.SeenFile(path)) {
dirty = true
dirtyFilePath = ""
break
}
}
return dirty || p.dirtyFilePath != dirtyFilePath
},
func(p *Project) {
p.dirty = true
p.dirtyFilePath = dirtyFilePath
if logger != nil {
if dirtyFilePath != "" {
logger.Logf("Marking project %s as dirty due to changes in %s", p.configFileName, dirtyFilePath)
} else {
logger.Logf("Marking project %s as dirty", p.configFileName)
}
}
},
)
}
func (b *ProjectCollectionBuilder) deleteConfiguredProject(project dirty.Value[*Project], logger *logging.LogTree) {
projectPath := project.Value().configFilePath
if logger != nil {
logger.Log("Deleting configured project: " + project.Value().configFileName)
}
if program := project.Value().Program; program != nil {
program.RangeResolvedProjectReference(func(referencePath tspath.Path, config *tsoptions.ParsedCommandLine, _ *tsoptions.ParsedCommandLine, _ int) bool {
b.configFileRegistryBuilder.releaseConfigForProject(referencePath, projectPath)
return true
})
}
b.configFileRegistryBuilder.releaseConfigForProject(projectPath, projectPath)
project.Delete()
}
// releaseDroppedProjectReferences releases the config entries for project references
// that were present in oldProgram but are no longer referenced by newProgram. Creating
// newProgram already re-acquires the config for every reference it still resolves, so
// only the dropped references need to be released here.
func (b *ProjectCollectionBuilder) releaseDroppedProjectReferences(oldProgram *compiler.Program, newProgram *compiler.Program, projectPath tspath.Path) {
if oldProgram == nil || oldProgram == newProgram {
return
}
var newReferences collections.Set[tspath.Path]
if newProgram != nil {
newProgram.RangeResolvedProjectReference(func(referencePath tspath.Path, _ *tsoptions.ParsedCommandLine, _ *tsoptions.ParsedCommandLine, _ int) bool {
newReferences.Add(referencePath)
return true
})
}
oldProgram.RangeResolvedProjectReference(func(referencePath tspath.Path, _ *tsoptions.ParsedCommandLine, _ *tsoptions.ParsedCommandLine, _ int) bool {
if !newReferences.Has(referencePath) {
b.configFileRegistryBuilder.releaseConfigForProject(referencePath, projectPath)
}
return true
})
}