kittenipc/kitcom/internal/tsgo/project/projectcollection.go
2025-10-15 10:12:44 +03:00

270 lines
9.2 KiB
Go

package project
import (
"cmp"
"slices"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/collections"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/core"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/tspath"
)
type ProjectCollection struct {
toPath func(fileName string) tspath.Path
configFileRegistry *ConfigFileRegistry
// fileDefaultProjects is a map of file paths to the config file path (the key
// into `configuredProjects`) of the default project for that file. If the file
// belongs to the inferred project, the value is `inferredProjectName`. This map
// contains quick lookups for only the associations discovered during the latest
// snapshot update.
fileDefaultProjects map[tspath.Path]tspath.Path
// configuredProjects is the set of loaded projects associated with a tsconfig
// file, keyed by the config file path.
configuredProjects map[tspath.Path]*Project
// inferredProject is a fallback project that is used when no configured
// project can be found for an open file.
inferredProject *Project
// apiOpenedProjects is the set of projects that should be kept open for
// API clients.
apiOpenedProjects map[tspath.Path]struct{}
}
func (c *ProjectCollection) ConfiguredProject(path tspath.Path) *Project {
return c.configuredProjects[path]
}
func (c *ProjectCollection) GetProjectByPath(projectPath tspath.Path) *Project {
if project, ok := c.configuredProjects[projectPath]; ok {
return project
}
if projectPath == inferredProjectName {
return c.inferredProject
}
return nil
}
// ConfiguredProjects returns all configured projects in a stable order.
func (c *ProjectCollection) ConfiguredProjects() []*Project {
projects := make([]*Project, 0, len(c.configuredProjects))
c.fillConfiguredProjects(&projects)
return projects
}
func (c *ProjectCollection) fillConfiguredProjects(projects *[]*Project) {
for _, p := range c.configuredProjects {
*projects = append(*projects, p)
}
slices.SortFunc(*projects, func(a, b *Project) int {
return cmp.Compare(a.Name(), b.Name())
})
}
// ProjectsByPath returns an ordered map of configured projects keyed by their config file path,
// plus the inferred project, if it exists, with the key `inferredProjectName`.
func (c *ProjectCollection) ProjectsByPath() *collections.OrderedMap[tspath.Path, *Project] {
projects := collections.NewOrderedMapWithSizeHint[tspath.Path, *Project](
len(c.configuredProjects) + core.IfElse(c.inferredProject != nil, 1, 0),
)
for _, project := range c.ConfiguredProjects() {
projects.Set(project.configFilePath, project)
}
if c.inferredProject != nil {
projects.Set(inferredProjectName, c.inferredProject)
}
return projects
}
// Projects returns all projects, including the inferred project if it exists, in a stable order.
func (c *ProjectCollection) Projects() []*Project {
if c.inferredProject == nil {
return c.ConfiguredProjects()
}
projects := make([]*Project, 0, len(c.configuredProjects)+1)
c.fillConfiguredProjects(&projects)
projects = append(projects, c.inferredProject)
return projects
}
func (c *ProjectCollection) InferredProject() *Project {
return c.inferredProject
}
// !!! result could be cached
func (c *ProjectCollection) GetDefaultProject(fileName string, path tspath.Path) *Project {
if result, ok := c.fileDefaultProjects[path]; ok {
if result == inferredProjectName {
return c.inferredProject
}
return c.configuredProjects[result]
}
var (
containingProjects []*Project
firstConfiguredProject *Project
firstNonSourceOfProjectReferenceRedirect *Project
multipleDirectInclusions bool
)
for _, p := range c.ConfiguredProjects() {
if p.containsFile(path) {
containingProjects = append(containingProjects, p)
if !multipleDirectInclusions && !p.IsSourceFromProjectReference(path) {
if firstNonSourceOfProjectReferenceRedirect == nil {
firstNonSourceOfProjectReferenceRedirect = p
} else {
multipleDirectInclusions = true
}
}
if firstConfiguredProject == nil {
firstConfiguredProject = p
}
}
}
if len(containingProjects) == 1 {
return containingProjects[0]
}
if len(containingProjects) == 0 {
if c.inferredProject != nil && c.inferredProject.containsFile(path) {
return c.inferredProject
}
return nil
}
if !multipleDirectInclusions {
if firstNonSourceOfProjectReferenceRedirect != nil {
// Multiple projects include the file, but only one is a direct inclusion.
return firstNonSourceOfProjectReferenceRedirect
}
// Multiple projects include the file, and none are direct inclusions.
return firstConfiguredProject
}
// Multiple projects include the file directly.
if defaultProject := c.findDefaultConfiguredProject(fileName, path); defaultProject != nil {
return defaultProject
}
return firstConfiguredProject
}
func (c *ProjectCollection) findDefaultConfiguredProject(fileName string, path tspath.Path) *Project {
if configFileName := c.configFileRegistry.GetConfigFileName(path); configFileName != "" {
return c.findDefaultConfiguredProjectWorker(fileName, path, configFileName, nil, nil)
}
return nil
}
func (c *ProjectCollection) findDefaultConfiguredProjectWorker(fileName string, path tspath.Path, configFileName string, visited *collections.SyncSet[*Project], fallback *Project) *Project {
configFilePath := c.toPath(configFileName)
project, ok := c.configuredProjects[configFilePath]
if !ok {
return nil
}
if visited == nil {
visited = &collections.SyncSet[*Project]{}
}
// Look in the config's project and its references recursively.
search := core.BreadthFirstSearchParallelEx(
project,
func(project *Project) []*Project {
if project.CommandLine == nil {
return nil
}
return core.Map(project.CommandLine.ResolvedProjectReferencePaths(), func(configFileName string) *Project {
return c.configuredProjects[c.toPath(configFileName)]
})
},
func(project *Project) (isResult bool, stop bool) {
if project.containsFile(path) {
return true, !project.IsSourceFromProjectReference(path)
}
return false, false
},
core.BreadthFirstSearchOptions[*Project, *Project]{
Visited: visited,
},
core.Identity,
)
if search.Stopped {
// If we found a project that directly contains the file, return it.
return search.Path[0]
}
if len(search.Path) > 0 && fallback == 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 = search.Path[0]
}
// 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 := c.configFileRegistry.GetConfig(path); config != nil && config.CompilerOptions().DisableSolutionSearching.IsTrue() {
return fallback
}
if ancestorConfigName := c.configFileRegistry.GetAncestorConfigFileName(path, configFileName); ancestorConfigName != "" {
return c.findDefaultConfiguredProjectWorker(fileName, path, ancestorConfigName, visited, fallback)
}
return fallback
}
// clone creates a shallow copy of the project collection.
func (c *ProjectCollection) clone() *ProjectCollection {
return &ProjectCollection{
toPath: c.toPath,
configuredProjects: c.configuredProjects,
inferredProject: c.inferredProject,
fileDefaultProjects: c.fileDefaultProjects,
}
}
// findDefaultConfiguredProjectFromProgramInclusion finds the default configured project for a file
// based on the file's inclusion in existing projects. The projects should be sorted, as ties will
// be broken by slice order. `getProject` should return a project with an up-to-date program.
// Along with the resulting project path, a boolean is returned indicating whether there were multiple
// direct inclusions of the file in different projects, indicating that the caller may want to perform
// additional logic to determine the best project.
func findDefaultConfiguredProjectFromProgramInclusion(
fileName string,
path tspath.Path,
projectPaths []tspath.Path,
getProject func(tspath.Path) *Project,
) (result tspath.Path, multipleCandidates bool) {
var (
containingProjects []tspath.Path
firstConfiguredProject tspath.Path
firstNonSourceOfProjectReferenceRedirect tspath.Path
multipleDirectInclusions bool
)
for _, projectPath := range projectPaths {
p := getProject(projectPath)
if p.containsFile(path) {
containingProjects = append(containingProjects, projectPath)
if !multipleDirectInclusions && !p.IsSourceFromProjectReference(path) {
if firstNonSourceOfProjectReferenceRedirect == "" {
firstNonSourceOfProjectReferenceRedirect = projectPath
} else {
multipleDirectInclusions = true
}
}
if firstConfiguredProject == "" {
firstConfiguredProject = projectPath
}
}
}
if len(containingProjects) == 1 {
return containingProjects[0], false
}
if !multipleDirectInclusions {
if firstNonSourceOfProjectReferenceRedirect != "" {
// Multiple projects include the file, but only one is a direct inclusion.
return firstNonSourceOfProjectReferenceRedirect, false
}
// Multiple projects include the file, and none are direct inclusions.
return firstConfiguredProject, false
}
// Multiple projects include the file directly.
return firstConfiguredProject, true
}