package project import ( "fmt" "maps" "slices" "strings" "github.com/microsoft/typescript-go/internal/collections" "github.com/microsoft/typescript-go/internal/core" "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" "github.com/microsoft/typescript-go/internal/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 { hasRelativePatternCapability bool fs *sourceFS isOpenFile func(tspath.Path) bool extendedConfigCache *ExtendedConfigCache snapshotID uint64 sessionOptions *SessionOptions customConfigFileName string base *ConfigFileRegistry configs *dirty.SyncMap[tspath.Path, *configFileEntry] configFileNames *dirty.Map[tspath.Path, *configFileNames] customConfigFileNameChanged bool } func newConfigFileRegistryBuilder( hasRelativePatternCapability bool, fs *snapshotFSBuilder, oldConfigFileRegistry *ConfigFileRegistry, extendedConfigCache *ExtendedConfigCache, snapshotID uint64, sessionOptions *SessionOptions, customConfigFileName string, logger *logging.LogTree, ) *configFileRegistryBuilder { return &configFileRegistryBuilder{ hasRelativePatternCapability: hasRelativePatternCapability, fs: newSourceFS(false, fs, fs.toPath), isOpenFile: fs.isOpenFile, base: oldConfigFileRegistry, sessionOptions: sessionOptions, extendedConfigCache: extendedConfigCache, snapshotID: snapshotID, customConfigFileName: customConfigFileName, customConfigFileNameChanged: customConfigFileName != oldConfigFileRegistry.customConfigFileName, configs: dirty.NewSyncMap(oldConfigFileRegistry.configs), 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 } if c.customConfigFileNameChanged { ensureCloned() newRegistry.customConfigFileName = c.customConfigFileName } return newRegistry } func (c *configFileRegistryBuilder) findOrAcquireConfigForFile( configFileName string, configFilePath tspath.Path, filePath 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.acquireConfigForFile(configFileName, configFilePath, filePath, 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) case PendingReloadFull: logger.Log("Loading config file: " + fileName) oldCommandLine := entry.commandLine entry.commandLine, _ = tsoptions.GetParsedCommandLineOfConfigFilePath(fileName, path, nil, nil /*optionsRaw*/, c, c) c.updateExtendingConfigs(path, entry.commandLine, oldCommandLine) 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(extendedConfig, 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{ patternsInsideWorkspace: 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(c.hasRelativePatternCapability, 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 } // acquireConfigForFile 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 `acquireConfigForFile` call that passes an `openFilePath` // should be accompanied by an eventual `releaseConfigForOpenFile` call with the same open file. func (c *configFileRegistryBuilder) acquireConfigForFile(configFileName string, configFilePath tspath.Path, filePath tspath.Path, logger *logging.LogTree) *tsoptions.ParsedCommandLine { entry, _ := c.configs.LoadOrStore(configFilePath, newConfigFileEntry(c.hasRelativePatternCapability, configFileName)) var needsRetainOpenFile bool entry.ChangeIf( func(config *configFileEntry) bool { if c.isOpenFile(filePath) { _, alreadyRetaining := config.retainingOpenFiles[filePath] 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[filePath] = 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) { if tspath.IsDynamicFileName(string(path)) { return } 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) DidChangeCustomConfigFileName(logger *logging.LogTree) bool { if !c.customConfigFileNameChanged { return false } c.configFileNames.Clear() return true } func (c *configFileRegistryBuilder) invalidateCache(logger *logging.LogTree) changeFileResult { var affectedProjects map[tspath.Path]struct{} var affectedFiles map[tspath.Path]struct{} logger.Log("Too many files changed; marking all configs for reload") c.configFileNames.Range(func(entry *dirty.MapEntry[tspath.Path, *configFileNames]) bool { if affectedFiles == nil { affectedFiles = make(map[tspath.Path]struct{}) } affectedFiles[entry.Key()] = struct{}{} return true }) c.configFileNames.Clear() c.configs.Range(func(entry *dirty.SyncMapEntry[tspath.Path, *configFileEntry]) bool { entry.Change(func(entry *configFileEntry) { affectedProjects = core.CopyMapInto(affectedProjects, entry.retainingProjects) if entry.pendingReload != PendingReloadFull { text, ok := c.FS().ReadFile(entry.fileName) if !ok || entry.commandLine == nil || text != entry.commandLine.ConfigFile.SourceFile.Text() { entry.pendingReload = PendingReloadFull } else { entry.pendingReload = PendingReloadFileNames } } }) return true }) return changeFileResult{ affectedProjects: affectedProjects, affectedFiles: affectedFiles, } } func (c *configFileRegistryBuilder) isConfigBaseName(baseName string) bool { return baseName == "tsconfig.json" || baseName == "jsconfig.json" || (c.customConfigFileName != "" && baseName == c.customConfigFileName) } func (c *configFileRegistryBuilder) DidChangeFiles(summary FileChangeSummary, logger *logging.LogTree) changeFileResult { var affectedProjects map[tspath.Path]struct{} var affectedFiles map[tspath.Path]struct{} var shouldInvalidateCache bool logger.Log("Summarizing file changes") hasExcessiveChanges := summary.HasExcessiveWatchEvents() && summary.IncludesWatchChangeOutsideNodeModules createdFiles := make(map[tspath.Path]string, summary.Created.Len()) deletedFiles := make(map[tspath.Path]string, summary.Deleted.Len()) createdOrDeletedConfigFiles := make(map[tspath.Path]struct{}) 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) baseName := tspath.GetBaseFileName(string(path)) if c.isConfigBaseName(baseName) { createdOrDeletedConfigFiles[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) deletedFiles[path] = fileName baseName := tspath.GetBaseFileName(string(path)) if c.isConfigBaseName(baseName) { createdOrDeletedConfigFiles[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 baseName := tspath.GetBaseFileName(string(path)) if c.isConfigBaseName(baseName) { createdOrDeletedConfigFiles[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.Keys() { 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 { if hasExcessiveChanges { return c.invalidateCache(logger) } 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 created/deleted files named "tsconfig.json" or "jsconfig.json" for path := range createdOrDeletedConfigFiles { if hasExcessiveChanges { return c.invalidateCache(logger) } 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 }) } // Handle deletions of wildcard-included root files for path, fileName := range deletedFiles { c.configs.Range(func(entry *dirty.SyncMapEntry[tspath.Path, *configFileEntry]) bool { entry.ChangeIf( func(config *configFileEntry) bool { if config.pendingReload != PendingReloadNone || config.commandLine == nil { return false } if _, ok := config.commandLine.FileNamesByPath()[path]; ok { // If the file is included in FileNames() but not matched by literal "files", it must be // included via wildcard, which means a reload of filenames will remove it from the list. // (Files explicitly specified in "files" are always included in the ParsedCommandLine, // triggering a missing root file error during program construction.) return config.commandLine.GetMatchedFileSpec(fileName) == "" } 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()) shouldInvalidateCache = hasExcessiveChanges }, ) return !shouldInvalidateCache }) if shouldInvalidateCache { return c.invalidateCache(logger) } } // 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 path, fileName := range createdFiles { if config.commandLine.PossiblyMatchesFileName(fileName) { return true } if config.commandLine.PossiblyMatchesDirectoryName(path) && c.fs.DirectoryExists(fileName) { // If we got a creation event for a directory, it's probably a symlink. We don't need to // test realpath here; this is enough confidence to trigger a filename reload. 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()) shouldInvalidateCache = hasExcessiveChanges }, ) return !shouldInvalidateCache }) if shouldInvalidateCache { return c.invalidateCache(logger) } } 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) // Prefer custom config file if provided; search ancestors with correct skip behavior. if c.customConfigFileName != "" { skip := skipSearchInDirectoryOfFile if result, _ := tspath.ForEachAncestorDirectory(searchPath, func(directory string) (result string, stop bool) { if !skip { customPath := tspath.CombinePaths(directory, c.customConfigFileName) if c.FS().FileExists(customPath) { return customPath, true } } if strings.HasSuffix(directory, "/node_modules") { return "", true } skip = false return "", false }); result != "" { logger.Logf("computeConfigFileName:: File: %s:: Result: %s", fileName, result) return result } } // When searching for ancestor of a config file, determine which config types to skip // in the starting directory. This matches TSServer's forEachConfigFileLocation behavior: // - For ancestor of tsconfig.json: skip tsconfig.json but still check jsconfig.json // - For ancestor of jsconfig.json: skip both tsconfig.json and jsconfig.json skipTsconfig := skipSearchInDirectoryOfFile skipJsconfig := skipSearchInDirectoryOfFile && !strings.HasSuffix(fileName, "/tsconfig.json") result, _ := tspath.ForEachAncestorDirectory(searchPath, func(directory string) (result string, stop bool) { if !skipTsconfig { tsconfigPath := tspath.CombinePaths(directory, "tsconfig.json") if c.FS().FileExists(tsconfigPath) { return tsconfigPath, true } } if !skipJsconfig { jsconfigPath := tspath.CombinePaths(directory, "jsconfig.json") if c.FS().FileExists(jsconfigPath) { return jsconfigPath, true } } if strings.HasSuffix(directory, "/node_modules") { return "", true } skipTsconfig = false skipJsconfig = false return "", false }) logger.Logf("computeConfigFileName:: File: %s:: Result: %s", fileName, result) return result } func (c *configFileRegistryBuilder) getConfigFileNameForFile(fileName string, path tspath.Path, logger *logging.LogTree) string { if tspath.IsDynamicFileName(fileName) { return "" } if entry, ok := c.configFileNames.Get(path); ok { return entry.Value().nearestConfigFileName } configName := c.computeConfigFileName(fileName, false, logger) if c.isOpenFile(path) { c.configFileNames.Add(path, &configFileNames{ nearestConfigFileName: configName, }) } return configName } func (c *configFileRegistryBuilder) forEachConfigFileNameFor(path tspath.Path, cb func(configFileName string)) { if tspath.IsDynamicFileName(string(path)) { return } if entry, ok := c.configFileNames.Get(path); ok { configFileName := entry.Value().nearestConfigFileName for configFileName != "" { cb(configFileName) if ancestorConfigName, found := entry.Value().ancestors[configFileName]; found { configFileName = ancestorConfigName } else { return } } } } func (c *configFileRegistryBuilder) getAncestorConfigFileName(fileName string, path tspath.Path, configFileName string, logger *logging.LogTree) string { if tspath.IsDynamicFileName(fileName) { return "" } entry, ok := c.configFileNames.Get(path) if !ok { return "" } if ancestorConfigName, found := entry.Value().ancestors[configFileName]; found { return ancestorConfigName } // Look for config in parent folders of config file result := c.computeConfigFileName(configFileName, true, logger) if c.isOpenFile(path) { 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 } // 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, resolutionStack []tspath.Path, host tsoptions.ParseConfigHost) *tsoptions.ExtendedConfigCacheEntry { var content string fh := c.fs.GetFileByPath(fileName, path) if fh != nil { content = fh.Content() } return c.extendedConfigCache.LoadAndAcquire(path, c.snapshotID, ExtendedConfigParseArgs{ FileName: fileName, Content: content, FS: c.fs.source, ResolutionStack: resolutionStack, Host: host, Cache: c, }).ExtendedConfigCacheEntry } 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 }) }