package project import ( "fmt" "maps" "slices" "strings" "efprojects.com/kitten-ipc/kitcom/internal/tsgo/collections" "efprojects.com/kitten-ipc/kitcom/internal/tsgo/core" "efprojects.com/kitten-ipc/kitcom/internal/tsgo/project/dirty" "efprojects.com/kitten-ipc/kitcom/internal/tsgo/project/logging" "efprojects.com/kitten-ipc/kitcom/internal/tsgo/tsoptions" "efprojects.com/kitten-ipc/kitcom/internal/tsgo/tspath" "efprojects.com/kitten-ipc/kitcom/internal/tsgo/vfs" ) var ( _ tsoptions.ParseConfigHost = (*configFileRegistryBuilder)(nil) _ tsoptions.ExtendedConfigCache = (*configFileRegistryBuilder)(nil) ) // configFileRegistryBuilder tracks changes made on top of a previous // configFileRegistry, producing a new clone with `finalize()` after // all changes have been made. type configFileRegistryBuilder struct { fs *snapshotFSBuilder extendedConfigCache *extendedConfigCache sessionOptions *SessionOptions base *ConfigFileRegistry configs *dirty.SyncMap[tspath.Path, *configFileEntry] configFileNames *dirty.Map[tspath.Path, *configFileNames] } func newConfigFileRegistryBuilder( fs *snapshotFSBuilder, oldConfigFileRegistry *ConfigFileRegistry, extendedConfigCache *extendedConfigCache, sessionOptions *SessionOptions, logger *logging.LogTree, ) *configFileRegistryBuilder { return &configFileRegistryBuilder{ fs: fs, base: oldConfigFileRegistry, sessionOptions: sessionOptions, extendedConfigCache: extendedConfigCache, configs: dirty.NewSyncMap(oldConfigFileRegistry.configs, nil), configFileNames: dirty.NewMap(oldConfigFileRegistry.configFileNames), } } // Finalize creates a new configFileRegistry based on the changes made in the builder. // If no changes were made, it returns the original base registry. func (c *configFileRegistryBuilder) Finalize() *ConfigFileRegistry { var changed bool newRegistry := c.base ensureCloned := func() { if !changed { newRegistry = newRegistry.clone() changed = true } } if configs, changedConfigs := c.configs.Finalize(); changedConfigs { ensureCloned() newRegistry.configs = configs } if configFileNames, changedNames := c.configFileNames.Finalize(); changedNames { ensureCloned() newRegistry.configFileNames = configFileNames } return newRegistry } func (c *configFileRegistryBuilder) findOrAcquireConfigForOpenFile( configFileName string, configFilePath tspath.Path, openFilePath tspath.Path, loadKind projectLoadKind, logger *logging.LogTree, ) *tsoptions.ParsedCommandLine { switch loadKind { case projectLoadKindFind: if entry, ok := c.configs.Load(configFilePath); ok { return entry.Value().commandLine } return nil case projectLoadKindCreate: return c.acquireConfigForOpenFile(configFileName, configFilePath, openFilePath, logger) default: panic(fmt.Sprintf("unknown project load kind: %d", loadKind)) } } // reloadIfNeeded updates the command line of the config file entry based on its // pending reload state. This function should only be called from within the // Change() method of a dirty map entry. func (c *configFileRegistryBuilder) reloadIfNeeded(entry *configFileEntry, fileName string, path tspath.Path, logger *logging.LogTree) { switch entry.pendingReload { case PendingReloadFileNames: logger.Log("Reloading file names for config: " + fileName) entry.commandLine = entry.commandLine.ReloadFileNamesOfParsedCommandLine(c.fs.fs) case PendingReloadFull: logger.Log("Loading config file: " + fileName) entry.commandLine, _ = tsoptions.GetParsedCommandLineOfConfigFilePath(fileName, path, nil, c, c) c.updateExtendingConfigs(path, entry.commandLine, entry.commandLine) c.updateRootFilesWatch(fileName, entry) logger.Log("Finished loading config file") default: return } entry.pendingReload = PendingReloadNone } func (c *configFileRegistryBuilder) updateExtendingConfigs(extendingConfigPath tspath.Path, newCommandLine *tsoptions.ParsedCommandLine, oldCommandLine *tsoptions.ParsedCommandLine) { var newExtendedConfigPaths collections.Set[tspath.Path] if newCommandLine != nil { for _, extendedConfig := range newCommandLine.ExtendedSourceFiles() { extendedConfigPath := c.fs.toPath(extendedConfig) newExtendedConfigPaths.Add(extendedConfigPath) entry, loaded := c.configs.LoadOrStore(extendedConfigPath, newExtendedConfigFileEntry(extendingConfigPath)) if loaded { entry.ChangeIf( func(config *configFileEntry) bool { _, alreadyRetaining := config.retainingConfigs[extendingConfigPath] return !alreadyRetaining }, func(config *configFileEntry) { if config.retainingConfigs == nil { config.retainingConfigs = make(map[tspath.Path]struct{}) } config.retainingConfigs[extendingConfigPath] = struct{}{} }, ) } } } if oldCommandLine != nil { for _, extendedConfig := range oldCommandLine.ExtendedSourceFiles() { extendedConfigPath := c.fs.toPath(extendedConfig) if newExtendedConfigPaths.Has(extendedConfigPath) { continue } if entry, ok := c.configs.Load(extendedConfigPath); ok { entry.ChangeIf( func(config *configFileEntry) bool { _, exists := config.retainingConfigs[extendingConfigPath] return exists }, func(config *configFileEntry) { delete(config.retainingConfigs, extendingConfigPath) }, ) } } } } func (c *configFileRegistryBuilder) updateRootFilesWatch(fileName string, entry *configFileEntry) { if entry.rootFilesWatch == nil { return } var ignored map[string]struct{} var globs []string var externalDirectories []string var includeWorkspace bool var includeTsconfigDir bool tsconfigDir := tspath.GetDirectoryPath(fileName) wildcardDirectories := entry.commandLine.WildcardDirectories() comparePathsOptions := tspath.ComparePathsOptions{ CurrentDirectory: c.sessionOptions.CurrentDirectory, UseCaseSensitiveFileNames: c.FS().UseCaseSensitiveFileNames(), } for dir := range wildcardDirectories { if tspath.ContainsPath(c.sessionOptions.CurrentDirectory, dir, comparePathsOptions) { includeWorkspace = true } else if tspath.ContainsPath(tsconfigDir, dir, comparePathsOptions) { includeTsconfigDir = true } else { externalDirectories = append(externalDirectories, dir) } } for _, fileName := range entry.commandLine.LiteralFileNames() { if tspath.ContainsPath(c.sessionOptions.CurrentDirectory, fileName, comparePathsOptions) { includeWorkspace = true } else if tspath.ContainsPath(tsconfigDir, fileName, comparePathsOptions) { includeTsconfigDir = true } else { externalDirectories = append(externalDirectories, tspath.GetDirectoryPath(fileName)) } } if includeWorkspace { globs = append(globs, getRecursiveGlobPattern(c.sessionOptions.CurrentDirectory)) } if includeTsconfigDir { globs = append(globs, getRecursiveGlobPattern(tsconfigDir)) } for _, fileName := range entry.commandLine.ExtendedSourceFiles() { if includeWorkspace && tspath.ContainsPath(c.sessionOptions.CurrentDirectory, fileName, comparePathsOptions) { continue } globs = append(globs, fileName) } if len(externalDirectories) > 0 { commonParents, ignoredExternalDirs := tspath.GetCommonParents(externalDirectories, minWatchLocationDepth, getPathComponentsForWatching, comparePathsOptions) for _, parent := range commonParents { globs = append(globs, getRecursiveGlobPattern(parent)) } ignored = ignoredExternalDirs } slices.Sort(globs) entry.rootFilesWatch = entry.rootFilesWatch.Clone(patternsAndIgnored{ patterns: globs, ignored: ignored, }) } // acquireConfigForProject loads a config file entry from the cache, or parses it if not already // cached, then adds the project (if provided) to `retainingProjects` to keep it alive // in the cache. Each `acquireConfigForProject` call that passes a `project` should be accompanied // by an eventual `releaseConfigForProject` call with the same project. func (c *configFileRegistryBuilder) acquireConfigForProject(fileName string, path tspath.Path, project *Project, logger *logging.LogTree) *tsoptions.ParsedCommandLine { entry, _ := c.configs.LoadOrStore(path, newConfigFileEntry(fileName)) var needsRetainProject bool entry.ChangeIf( func(config *configFileEntry) bool { _, alreadyRetaining := config.retainingProjects[project.configFilePath] needsRetainProject = !alreadyRetaining return needsRetainProject || config.pendingReload != PendingReloadNone }, func(config *configFileEntry) { if needsRetainProject { if config.retainingProjects == nil { config.retainingProjects = make(map[tspath.Path]struct{}) } config.retainingProjects[project.configFilePath] = struct{}{} } c.reloadIfNeeded(config, fileName, path, logger) }, ) return entry.Value().commandLine } // acquireConfigForOpenFile loads a config file entry from the cache, or parses it if not already // cached, then adds the open file to `retainingOpenFiles` to keep it alive in the cache. // Each `acquireConfigForOpenFile` call that passes an `openFilePath` // should be accompanied by an eventual `releaseConfigForOpenFile` call with the same open file. func (c *configFileRegistryBuilder) acquireConfigForOpenFile(configFileName string, configFilePath tspath.Path, openFilePath tspath.Path, logger *logging.LogTree) *tsoptions.ParsedCommandLine { entry, _ := c.configs.LoadOrStore(configFilePath, newConfigFileEntry(configFileName)) var needsRetainOpenFile bool entry.ChangeIf( func(config *configFileEntry) bool { _, alreadyRetaining := config.retainingOpenFiles[openFilePath] needsRetainOpenFile = !alreadyRetaining return needsRetainOpenFile || config.pendingReload != PendingReloadNone }, func(config *configFileEntry) { if needsRetainOpenFile { if config.retainingOpenFiles == nil { config.retainingOpenFiles = make(map[tspath.Path]struct{}) } config.retainingOpenFiles[openFilePath] = struct{}{} } c.reloadIfNeeded(config, configFileName, configFilePath, logger) }, ) return entry.Value().commandLine } // releaseConfigForProject removes the project from the config entry. Once no projects // or files are associated with the config entry, it will be removed on the next call to `cleanup`. func (c *configFileRegistryBuilder) releaseConfigForProject(configFilePath tspath.Path, projectPath tspath.Path) { if entry, ok := c.configs.Load(configFilePath); ok { entry.ChangeIf( func(config *configFileEntry) bool { _, exists := config.retainingProjects[projectPath] return exists }, func(config *configFileEntry) { delete(config.retainingProjects, projectPath) }, ) } } // didCloseFile removes the open file from the config entry. Once no projects // or files are associated with the config entry, it will be removed on the next call to `cleanup`. func (c *configFileRegistryBuilder) didCloseFile(path tspath.Path) { c.configFileNames.Delete(path) c.configs.Range(func(entry *dirty.SyncMapEntry[tspath.Path, *configFileEntry]) bool { entry.ChangeIf( func(config *configFileEntry) bool { _, ok := config.retainingOpenFiles[path] return ok }, func(config *configFileEntry) { delete(config.retainingOpenFiles, path) }, ) return true }) } type changeFileResult struct { affectedProjects map[tspath.Path]struct{} affectedFiles map[tspath.Path]struct{} } func (r changeFileResult) IsEmpty() bool { return len(r.affectedProjects) == 0 && len(r.affectedFiles) == 0 } func (c *configFileRegistryBuilder) DidChangeFiles(summary FileChangeSummary, logger *logging.LogTree) changeFileResult { var affectedProjects map[tspath.Path]struct{} var affectedFiles map[tspath.Path]struct{} logger.Log("Summarizing file changes") createdFiles := make(map[tspath.Path]string, summary.Created.Len()) createdOrDeletedFiles := make(map[tspath.Path]struct{}, summary.Created.Len()+summary.Deleted.Len()) createdOrChangedOrDeletedFiles := make(map[tspath.Path]struct{}, summary.Changed.Len()+summary.Deleted.Len()) for uri := range summary.Changed.Keys() { if tspath.ContainsIgnoredPath(string(uri)) { continue } fileName := uri.FileName() path := c.fs.toPath(fileName) createdOrDeletedFiles[path] = struct{}{} createdOrChangedOrDeletedFiles[path] = struct{}{} } for uri := range summary.Deleted.Keys() { if tspath.ContainsIgnoredPath(string(uri)) { continue } fileName := uri.FileName() path := c.fs.toPath(fileName) createdOrDeletedFiles[path] = struct{}{} createdOrChangedOrDeletedFiles[path] = struct{}{} } for uri := range summary.Created.Keys() { if tspath.ContainsIgnoredPath(string(uri)) { continue } fileName := uri.FileName() path := c.fs.toPath(fileName) createdFiles[path] = fileName createdOrDeletedFiles[path] = struct{}{} createdOrChangedOrDeletedFiles[path] = struct{}{} } // Handle closed files - this ranges over config entries and could be combined // with the file change handling, but a separate loop is simpler and a snapshot // change with both closing and watch changes seems rare. for uri := range summary.Closed { fileName := uri.FileName() path := c.fs.toPath(fileName) c.didCloseFile(path) } // Handle changes to stored config files logger.Log("Checking if any changed files are config files") for path := range createdOrChangedOrDeletedFiles { if entry, ok := c.configs.Load(path); ok { affectedProjects = core.CopyMapInto(affectedProjects, c.handleConfigChange(entry, logger)) for extendingConfigPath := range entry.Value().retainingConfigs { if extendingConfigEntry, ok := c.configs.Load(extendingConfigPath); ok { affectedProjects = core.CopyMapInto(affectedProjects, c.handleConfigChange(extendingConfigEntry, logger)) } } // This was a config file, so assume it's not also a root file delete(createdFiles, path) } } // Handle possible root file creation if len(createdFiles) > 0 { c.configs.Range(func(entry *dirty.SyncMapEntry[tspath.Path, *configFileEntry]) bool { entry.ChangeIf( func(config *configFileEntry) bool { if config.commandLine == nil || config.rootFilesWatch == nil || config.pendingReload != PendingReloadNone { return false } logger.Logf("Checking if any of %d created files match root files for config %s", len(createdFiles), entry.Key()) for _, fileName := range createdFiles { if config.commandLine.PossiblyMatchesFileName(fileName) { return true } } return false }, func(config *configFileEntry) { config.pendingReload = PendingReloadFileNames if affectedProjects == nil { affectedProjects = make(map[tspath.Path]struct{}) } maps.Copy(affectedProjects, config.retainingProjects) logger.Logf("Root files for config %s changed", entry.Key()) }, ) return true }) } // Handle created/deleted files named "tsconfig.json" or "jsconfig.json" for path := range createdOrDeletedFiles { baseName := tspath.GetBaseFileName(string(path)) if baseName == "tsconfig.json" || baseName == "jsconfig.json" { directoryPath := path.GetDirectoryPath() c.configFileNames.Range(func(entry *dirty.MapEntry[tspath.Path, *configFileNames]) bool { if directoryPath.ContainsPath(entry.Key()) { if affectedFiles == nil { affectedFiles = make(map[tspath.Path]struct{}) } affectedFiles[entry.Key()] = struct{}{} entry.Delete() } return true }) } } return changeFileResult{ affectedProjects: affectedProjects, affectedFiles: affectedFiles, } } func (c *configFileRegistryBuilder) handleConfigChange(entry *dirty.SyncMapEntry[tspath.Path, *configFileEntry], logger *logging.LogTree) map[tspath.Path]struct{} { var affectedProjects map[tspath.Path]struct{} changed := entry.ChangeIf( func(config *configFileEntry) bool { return config.pendingReload != PendingReloadFull }, func(config *configFileEntry) { config.pendingReload = PendingReloadFull }, ) if changed { logger.Logf("Config file %s changed", entry.Key()) affectedProjects = maps.Clone(entry.Value().retainingProjects) } return affectedProjects } func (c *configFileRegistryBuilder) computeConfigFileName(fileName string, skipSearchInDirectoryOfFile bool, logger *logging.LogTree) string { searchPath := tspath.GetDirectoryPath(fileName) result, _ := tspath.ForEachAncestorDirectory(searchPath, func(directory string) (result string, stop bool) { tsconfigPath := tspath.CombinePaths(directory, "tsconfig.json") if !skipSearchInDirectoryOfFile && c.FS().FileExists(tsconfigPath) { return tsconfigPath, true } jsconfigPath := tspath.CombinePaths(directory, "jsconfig.json") if !skipSearchInDirectoryOfFile && c.FS().FileExists(jsconfigPath) { return jsconfigPath, true } if strings.HasSuffix(directory, "/node_modules") { return "", true } skipSearchInDirectoryOfFile = false return "", false }) logger.Logf("computeConfigFileName:: File: %s:: Result: %s", fileName, result) return result } func (c *configFileRegistryBuilder) getConfigFileNameForFile(fileName string, path tspath.Path, loadKind projectLoadKind, logger *logging.LogTree) string { if isDynamicFileName(fileName) { return "" } if entry, ok := c.configFileNames.Get(path); ok { return entry.Value().nearestConfigFileName } if loadKind == projectLoadKindFind { return "" } configName := c.computeConfigFileName(fileName, false, logger) if _, ok := c.fs.overlays[path]; ok { c.configFileNames.Add(path, &configFileNames{ nearestConfigFileName: configName, }) } return configName } func (c *configFileRegistryBuilder) getAncestorConfigFileName(fileName string, path tspath.Path, configFileName string, loadKind projectLoadKind, logger *logging.LogTree) string { if isDynamicFileName(fileName) { return "" } entry, ok := c.configFileNames.Get(path) if !ok { return "" } if ancestorConfigName, found := entry.Value().ancestors[configFileName]; found { return ancestorConfigName } if loadKind == projectLoadKindFind { return "" } // Look for config in parent folders of config file result := c.computeConfigFileName(configFileName, true, logger) if _, ok := c.fs.overlays[path]; ok { entry.Change(func(value *configFileNames) { if value.ancestors == nil { value.ancestors = make(map[string]string) } value.ancestors[configFileName] = result }) } return result } // FS implements tsoptions.ParseConfigHost. func (c *configFileRegistryBuilder) FS() vfs.FS { return c.fs.fs } // GetCurrentDirectory implements tsoptions.ParseConfigHost. func (c *configFileRegistryBuilder) GetCurrentDirectory() string { return c.sessionOptions.CurrentDirectory } // GetExtendedConfig implements tsoptions.ExtendedConfigCache. func (c *configFileRegistryBuilder) GetExtendedConfig(fileName string, path tspath.Path, parse func() *tsoptions.ExtendedConfigCacheEntry) *tsoptions.ExtendedConfigCacheEntry { fh := c.fs.GetFileByPath(fileName, path) return c.extendedConfigCache.Acquire(fh, path, parse) } func (c *configFileRegistryBuilder) Cleanup() { c.configs.Range(func(entry *dirty.SyncMapEntry[tspath.Path, *configFileEntry]) bool { entry.DeleteIf(func(value *configFileEntry) bool { return len(value.retainingProjects) == 0 && len(value.retainingOpenFiles) == 0 && len(value.retainingConfigs) == 0 }) return true }) }