diff --git a/plugins/GradleTypesafeConventions/CHANGELOG.md b/plugins/GradleTypesafeConventions/CHANGELOG.md index 38e8b64a..d43e82dd 100644 --- a/plugins/GradleTypesafeConventions/CHANGELOG.md +++ b/plugins/GradleTypesafeConventions/CHANGELOG.md @@ -29,6 +29,11 @@ rename support for version catalogs used from `buildSrc` and included build logic. - Resolve dotted and separator-normalized Kotlin catalog accessors to the exact TOML key segment, so Goto Declaration, Find Usages, and rename preserve the unaffected parts of an alias and ignore shadowed or programmatic-only accessors. +- Goto Declaration on the `versions`, `bundles`, and `plugins` token of a Kotlin catalog accessor now reaches the + matching TOML section instead of falling through to the generated accessor code. +- Find Usages on a `versions`, `bundles`, or `plugins` section name in a version catalog now finds the Kotlin + accessors using that section, including usages in `buildSrc` and included build logic, and reports usages of every + catalog even when several catalogs are searched together. - Preserve catalog navigation across sequential linked Gradle root syncs, failed or cancelled imports, IDE restarts, and unlink operations without replacing last-known-good state with partial model data. - Avoid blocking dynamic plugin unload by reusing Gradle-owned Workspace Model diff --git a/plugins/GradleTypesafeConventions/docs/GradleTypesafeConventions-TomlNavigation.md b/plugins/GradleTypesafeConventions/docs/GradleTypesafeConventions-TomlNavigation.md index 446f6543..f3ca0c9e 100644 --- a/plugins/GradleTypesafeConventions/docs/GradleTypesafeConventions-TomlNavigation.md +++ b/plugins/GradleTypesafeConventions/docs/GradleTypesafeConventions-TomlNavigation.md @@ -33,8 +33,16 @@ marker required before optional Kotlin configuration is loaded. - Kotlin catalog accessors resolve to the concrete `TomlKeySegment` declaration. - Goto Declaration targets the exact catalog key segment under the caret. +- The `versions` / `bundles` / `plugins` token of a Kotlin accessor selects a TOML section rather than an alias + segment, so the resolver maps it to the section owner recorded by the alias index: a standard table header, a + top-level dotted key, or an inline table. Only the alias selectors carry `TypesafeConventionsKotlinCatalogReference` + instances, so section tokens are handled by the goto handler after reference lookup misses. - Find Usages filters candidates by their resolved catalog file and entry, so catalogs with identical aliases do not cross-match. +- Find Usages on a section name resolves the usages whose accessor carries that section token. Section names are keyed + by the section plus the catalog the accessor resolves to, because the token is not an alias segment and therefore has + no TOML key to match against. A section name is a structural Gradle catalog key, so the reported usage references + resolve to the searched section but leave the token text untouched on rename. - Renaming from either a TOML key segment or Kotlin usage updates only the matching selector slice and preserves the remaining dotted alias. - Local variables that shadow catalog roots and programmatic-only aliases retain their native Kotlin references without @@ -61,20 +69,29 @@ rebuild the index, while an explicit catalog-file refresh advances the same gene entries are unchanged, invalidating PSI resolution caches exactly once for that publication. Each TOML catalog file keeps a PSI-dependent alias index by section, normalized alias, generated Groovy accessor name, -entry, and exact key segments. Kotlin reference creation caches immutable selector groups against the catalog-index +entry, and exact key segments. The same index records the key segment naming each section and the element owning it, so +section navigation, Find Usages, and the TOML use-scope enlargement share one section-to-catalog mapping instead of +re-deriving it per feature. Kotlin reference creation caches immutable selector groups against the catalog-index generation and TOML PSI; Groovy navigation reuses the same alias and section-owner mappings instead of scanning tables independently. Find Usages registers an indexed word request whose scope is the intersection of the user-selected scope and the Gradle build roots associated with the target catalog. The TOML use-scope enlargement uses the same roots, preventing unrelated -project files from becoming search candidates. +project files from becoming search candidates. Request results are deduplicated per occurrence inside the search session +after the usage is confirmed to belong to the searched catalog, so batching requests for several catalogs in one session +cannot let one catalog's claim silence another's. ## Verification Coverage includes focused TOML PSI tests and real Gradle sync tests for `buildSrc` and included build logic. The integration tests directly inspect `KtDotQualifiedExpression.references`, exercise registered goto handlers, perform `ReferencesSearch`, and run -`RenameProcessor` from TOML and Kotlin segments for both precompiled script and binary Kotlin convention plugins. State +`RenameProcessor` from TOML and Kotlin segments for both precompiled script and binary Kotlin convention plugins. +Section-token navigation is covered for both default and custom catalogs across every convention build, asserting the +resolved target is the section owner from the TOML alias index. Section Find Usages is covered both through +`ReferencesSearch` and through the default Find Usages pipeline, including the `plugins` section used from precompiled +script `plugins` blocks, isolation between same-named sections of different catalogs, and several catalogs batched into +one search session. State coverage includes sequential linked roots, successful disable, failed and cancelled imports, null-path commits, unlink cleanup, restart recovery, and rejection of incomplete Workspace Model candidates. Structural performance coverage verifies Workspace Model index reuse and invalidation, TOML PSI cache invalidation, build-root search scoping, and diff --git a/plugins/GradleTypesafeConventions/src/main/kotlin/dev/ghostflyby/typesafeconventions/gradle/TypesafeConventionsKotlinCatalogReference.kt b/plugins/GradleTypesafeConventions/src/main/kotlin/dev/ghostflyby/typesafeconventions/gradle/TypesafeConventionsKotlinCatalogReference.kt index 5d550495..f015f42c 100644 --- a/plugins/GradleTypesafeConventions/src/main/kotlin/dev/ghostflyby/typesafeconventions/gradle/TypesafeConventionsKotlinCatalogReference.kt +++ b/plugins/GradleTypesafeConventions/src/main/kotlin/dev/ghostflyby/typesafeconventions/gradle/TypesafeConventionsKotlinCatalogReference.kt @@ -30,9 +30,7 @@ import org.jetbrains.kotlin.idea.references.mainReference import org.jetbrains.kotlin.lexer.KtTokens import org.jetbrains.kotlin.psi.* import org.jetbrains.kotlin.references.KotlinPsiReferenceProviderContributor -import org.toml.lang.psi.TomlFile -import org.toml.lang.psi.TomlKeySegment -import org.toml.lang.psi.TomlKeyValue +import org.toml.lang.psi.* import java.util.concurrent.ConcurrentHashMap internal data class TypesafeConventionsKotlinCatalogAccessor( @@ -49,6 +47,17 @@ internal data class TypesafeConventionsKotlinCatalogAccessor( val aliasPath: String get() = aliasSelectorNames.joinToString(".") + + /** + * The `versions` / `bundles` / `plugins` token selecting the catalog section. It is not an alias + * segment: it names the TOML section itself. `null` for `libraries`, which has no such token. + */ + val sectionExpression: KtNameReferenceExpression? + get() = if (section == TypesafeConventionsCatalogSection.LIBRARIES) { + null + } else { + nameExpressions.getOrNull(1) + } } internal data class TypesafeConventionsKotlinCatalogSelectorGroup( @@ -58,11 +67,25 @@ internal data class TypesafeConventionsKotlinCatalogSelectorGroup( val targetSegment: TomlKeySegment, ) -private data class TypesafeConventionsKotlinCatalogSearchTarget( - val section: TypesafeConventionsCatalogSection, - val searchWord: String, - val catalogBuildRoots: List, -) +internal sealed interface TypesafeConventionsKotlinCatalogSearchTarget { + val section: TypesafeConventionsCatalogSection + val searchWord: String + val catalogBuildRoots: List +} + +private data class TypesafeConventionsKotlinCatalogAliasSearchTarget( + override val section: TypesafeConventionsCatalogSection, + override val searchWord: String, + override val catalogBuildRoots: List, + val keySegment: TomlKeySegment, +) : TypesafeConventionsKotlinCatalogSearchTarget + +private data class TypesafeConventionsKotlinCatalogSectionSearchTarget( + override val section: TypesafeConventionsCatalogSection, + override val searchWord: String, + override val catalogBuildRoots: List, + val catalogUrl: String, +) : TypesafeConventionsKotlinCatalogSearchTarget internal open class TypesafeConventionsKotlinCatalogReference( expression: KtDotQualifiedExpression, @@ -117,17 +140,40 @@ internal class TypesafeConventionsKotlinCatalogGotoDeclarationHandler : GotoDecl val reference = expression.references .filterIsInstance() .firstOrNull { it.rangeInElement.containsOffset(relativeOffset) } - ?: return null - return reference.resolve()?.let { arrayOf(it) } + return reference?.resolve()?.let { arrayOf(it) } + ?: resolveTypesafeConventionsCatalogSection(expression, relativeOffset)?.let { arrayOf(it) } } } +/** + * Resolves the section token of `libs.versions.foo` / `libs.bundles.foo.bar` / `libs.plugins.foo` to + * its TOML section. Alias selectors carry references, but the section token does not, so without this + * the caret on `versions` or `bundles` has no target at all. + */ +@RequiresReadLock +@RequiresBackgroundThread +private fun resolveTypesafeConventionsCatalogSection( + expression: KtDotQualifiedExpression, + relativeOffset: Int, +): PsiElement? { + val accessor = expression.typesafeConventionsCatalogAccessor() ?: return null + val sectionExpression = accessor.sectionExpression ?: return null + if (!expression.relativeRange(sectionExpression, sectionExpression).containsOffset(relativeOffset)) { + return null + } + if (!accessor.resolvesToTypesafeConventionsEntrypoint()) { + return null + } + val tomlFile = findTypesafeConventionsCatalogTomlFile(expression, accessor.catalogName) ?: return null + return typesafeConventionsTomlCatalogAliasIndex(tomlFile).sectionOwner(accessor.section) +} + internal class TypesafeConventionsKotlinCatalogUseScopeEnlarger : UseScopeEnlarger() { @RequiresReadLock override fun getAdditionalUseScope(element: PsiElement): SearchScope? { - val keySegment = element as? TomlKeySegment ?: return null - val target = keySegment.typesafeConventionsKotlinCatalogSearchTarget() ?: return null + // Section names are addressed through the owning table or inline table, not through a key segment. + val target = element.typesafeConventionsKotlinCatalogSearchTarget() ?: return null return typesafeConventionsCatalogBuildRootsSearchScope(element.project, target.catalogBuildRoots) } } @@ -162,22 +208,25 @@ internal class TypesafeConventionsKotlinCatalogReferencesSearcher : queryParameters: ReferencesSearch.SearchParameters, consumer: Processor, ) { - val keySegment = queryParameters.elementToSearch as? TomlKeySegment ?: return - val target = keySegment.typesafeConventionsKotlinCatalogSearchTarget() ?: return + val searchedElement = queryParameters.elementToSearch + val target = searchedElement.typesafeConventionsKotlinCatalogSearchTarget() ?: return val searchSession = queryParameters.optimizer.searchSession - val processedGroups = synchronized(searchSession) { - searchSession.getUserData(PROCESSED_CATALOG_SELECTOR_GROUPS_KEY) - ?: ConcurrentHashMap.newKeySet().also { groups -> - searchSession.putUserData(PROCESSED_CATALOG_SELECTOR_GROUPS_KEY, groups) - } + val resultProcessor = when (target) { + is TypesafeConventionsKotlinCatalogAliasSearchTarget -> CatalogReferenceRequestProcessor( + searchedSegment = target.keySegment, + section = target.section, + processedGroups = searchSession.processedOccurrences(PROCESSED_CATALOG_SELECTOR_GROUPS_KEY), + ) + + is TypesafeConventionsKotlinCatalogSectionSearchTarget -> CatalogSectionRequestProcessor( + searchedSection = target.section, + searchedSectionElement = searchedElement, + searchedCatalogUrl = target.catalogUrl, + processedOccurrences = searchSession.processedOccurrences(PROCESSED_CATALOG_SECTION_TOKENS_KEY), + ) } - val resultProcessor = CatalogReferenceRequestProcessor( - keySegment, - target.section, - processedGroups, - ) val buildScope = typesafeConventionsCatalogBuildRootsSearchScope( - keySegment.project, + searchedElement.project, target.catalogBuildRoots, ) val searchScope = queryParameters.scopeDeterminedByUser.intersectWith(buildScope) @@ -186,7 +235,7 @@ internal class TypesafeConventionsKotlinCatalogReferencesSearcher : searchScope, UsageSearchContext.IN_CODE, false, - keySegment, + searchedElement, resultProcessor, ) } @@ -239,6 +288,99 @@ internal class TypesafeConventionsKotlinCatalogReferencesSearcher : ) } } + + /** + * Reports every Kotlin usage of a catalog section token (`libs.versions` / `libs.bundles` / + * `libs.plugins`). The token is not an alias segment, so it cannot be matched through the TOML alias + * index; usages are matched by section plus the catalog the accessor resolves to. + */ + private class CatalogSectionRequestProcessor( + private val searchedSection: TypesafeConventionsCatalogSection, + private val searchedSectionElement: PsiElement, + private val searchedCatalogUrl: String, + private val processedOccurrences: MutableSet, + ) : RequestResultProcessor(searchedSection, searchedCatalogUrl) { + @RequiresReadLock + override fun processTextOccurrence( + element: PsiElement, + offsetInElement: Int, + consumer: Processor, + ): Boolean { + val occurrence = element as? KtNameReferenceExpression ?: return true + val expression = occurrence.findTypesafeConventionsCatalogExpression() ?: return true + val accessor = expression.typesafeConventionsCatalogAccessor() ?: return true + if (accessor.section != searchedSection) { + return true + } + val sectionExpression = accessor.sectionExpression ?: return true + val absoluteOffset = occurrence.textRange.startOffset + offsetInElement + if (!(sectionExpression === occurrence || sectionExpression.textRange.containsOffset(absoluteOffset))) { + return true + } + if (!accessor.resolvesToTypesafeConventionsEntrypoint()) { + return true + } + val catalogUrl = findTypesafeConventionsCatalogTomlFile(expression, accessor.catalogName) + ?.originalFile + ?.virtualFile + ?.url + ?: return true + if (catalogUrl != searchedCatalogUrl) { + return true + } + // Deduplicate only once the usage is known to belong to the searched catalog: the occurrence set is + // session-scoped, and a single search session can batch requests for several catalogs, so marking an + // occurrence for the wrong catalog would silence the request that legitimately owns it. + val expressionFileUrl = expression.containingFile.virtualFile?.url ?: return true + return !processedOccurrences.add( + ProcessedCatalogSectionToken( + expressionFileUrl, + sectionExpression.textRange.startOffset, + searchedSection, + ), + ) || consumer.process( + TypesafeConventionsKotlinCatalogSectionUsageReference( + expression, + expression.relativeRange(sectionExpression, sectionExpression), + searchedSectionElement, + ), + ) + } + } +} + +/** + * A Kotlin usage of a catalog section token reported to Find Usages. The token addresses the TOML section + * itself, so the reference resolves to the searched section element and leaves the token text alone: a + * section name is a structural Gradle catalog key, and rewriting it here would change what the expression + * selects. + */ +internal class TypesafeConventionsKotlinCatalogSectionUsageReference( + expression: KtDotQualifiedExpression, + rangeInElement: TextRange, + private val searchedSectionElement: PsiElement, +) : PsiReferenceBase(expression, rangeInElement, true) { + + override fun resolve(): PsiElement? = searchedSectionElement.takeIf(PsiElement::isValid) + + override fun handleElementRename(newElementName: String): PsiElement = element + + override fun equals(other: Any?): Boolean = + this === other || other is TypesafeConventionsKotlinCatalogSectionUsageReference && identity == other.identity + + override fun hashCode(): Int = identity.hashCode() + + private val identity = UsageReferenceIdentity( + fileUrl = expression.containingFile.virtualFile?.url, + expressionStartOffset = expression.textRange.startOffset, + rangeInElement = rangeInElement, + ) + + private data class UsageReferenceIdentity( + val fileUrl: String?, + val expressionStartOffset: Int, + val rangeInElement: TextRange, + ) } private data class ProcessedSelectorGroup( @@ -248,8 +390,44 @@ private data class ProcessedSelectorGroup( val selectorEndIndex: Int, ) +private data class ProcessedCatalogSectionToken( + val expressionFileUrl: String, + val sectionTokenStartOffset: Int, + val section: TypesafeConventionsCatalogSection, +) + +/** + * The per-search set of already reported occurrences. A word occurrence reaches + * [RequestResultProcessor.processTextOccurrence] once per enclosing PSI element, so reporting is deduplicated + * per occurrence instead of per callback. + */ +private fun SearchSession.processedOccurrences( + key: Key>, +): MutableSet = + synchronized(this) { + getUserData(key) + ?: ConcurrentHashMap.newKeySet().also { occurrences -> + putUserData(key, occurrences) + } + } + @RequiresReadLock -private fun TomlKeySegment.typesafeConventionsKotlinCatalogSearchTarget(): +internal fun PsiElement.typesafeConventionsKotlinCatalogSearchTarget(): + TypesafeConventionsKotlinCatalogSearchTarget? = + when (this) { + is TomlKeySegment -> typesafeConventionsKotlinCatalogAliasSearchTarget() + ?: typesafeConventionsKotlinCatalogSectionSearchTarget() + + is TomlTable -> typesafeConventionsKotlinCatalogSectionSearchTarget() + is TomlInlineTable -> typesafeConventionsKotlinCatalogSectionSearchTarget() + else -> null + } + +/** + * A TOML alias key segment, resolved through the catalog alias index. + */ +@RequiresReadLock +private fun TomlKeySegment.typesafeConventionsKotlinCatalogAliasSearchTarget(): TypesafeConventionsKotlinCatalogSearchTarget? { val keyValue = parentOfType(withSelf = false) ?: return null val alias = findTypesafeConventionsTomlCatalogAlias(keyValue) ?: return null @@ -265,10 +443,45 @@ private fun TomlKeySegment.typesafeConventionsKotlinCatalogSearchTarget(): if (catalogBuildRoots.isEmpty()) { return null } - return TypesafeConventionsKotlinCatalogSearchTarget( + return TypesafeConventionsKotlinCatalogAliasSearchTarget( section = alias.section, searchWord = searchWord, catalogBuildRoots = catalogBuildRoots, + keySegment = this, + ) +} + +/** + * A TOML section name, in any of the shapes the alias index understands: a standard table header, an inline + * table key, or the leading segment of a top-level dotted key. Kotlin usages of such a section appear as the + * section token of an accessor (`libs.bundles.junit.bundle`), so the searched word is the section name. + */ +@RequiresReadLock +private fun PsiElement.typesafeConventionsKotlinCatalogSectionSearchTarget(): + TypesafeConventionsKotlinCatalogSearchTarget? { + val catalogFile = containingFile as? TomlFile ?: return null + val aliasIndex = typesafeConventionsTomlCatalogAliasIndex(catalogFile) + val section = when (this) { + is TomlKeySegment -> aliasIndex.sectionForSectionNameSegment(this) + is TomlTable -> aliasIndex.sectionForSectionOwner(this) + is TomlInlineTable -> aliasIndex.sectionForSectionOwner(this) + else -> null + } ?: return null + // `libraries` has no accessor token (`libs.foo`, not `libs.libraries.foo`), so no Kotlin usage can name it. + if (section == TypesafeConventionsCatalogSection.LIBRARIES) { + return null + } + val searchWord = section.tomlName + val catalogBuildRoots = findTypesafeConventionsCatalogBuildRoots(catalogFile) + if (catalogBuildRoots.isEmpty()) { + return null + } + val catalogUrl = catalogFile.originalFile.virtualFile?.url ?: return null + return TypesafeConventionsKotlinCatalogSectionSearchTarget( + section = section, + searchWord = searchWord, + catalogBuildRoots = catalogBuildRoots, + catalogUrl = catalogUrl, ) } @@ -305,8 +518,10 @@ internal fun KtDotQualifiedExpression.typesafeConventionsCatalogAccessor(): } val section = TypesafeConventionsCatalogSection.fromAccessorPrefix(names[1]) ?: TypesafeConventionsCatalogSection.LIBRARIES + // A section token (`libs.versions`, `libs.bundles`, `libs.plugins`) may carry no alias selector: + // it addresses the TOML section itself. val aliasSelectorStartIndex = if (section == TypesafeConventionsCatalogSection.LIBRARIES) 1 else 2 - if (aliasSelectorStartIndex >= names.size) { + if (aliasSelectorStartIndex > names.size) { return null } return TypesafeConventionsKotlinCatalogAccessor( @@ -566,6 +781,11 @@ private val PROCESSED_CATALOG_SELECTOR_GROUPS_KEY = "typesafe.conventions.kotlin.catalog.processed.selector.groups", ) +private val PROCESSED_CATALOG_SECTION_TOKENS_KEY = + Key.create>( + "typesafe.conventions.kotlin.catalog.processed.section.tokens", + ) + private val GRADLE_ENTRYPOINT_RECEIVER_FQ_NAMES = setOf( "org.gradle.api.Project", diff --git a/plugins/GradleTypesafeConventions/src/main/kotlin/dev/ghostflyby/typesafeconventions/gradle/TypesafeConventionsTomlCatalogResolver.kt b/plugins/GradleTypesafeConventions/src/main/kotlin/dev/ghostflyby/typesafeconventions/gradle/TypesafeConventionsTomlCatalogResolver.kt index 74c053ed..45c83f6f 100644 --- a/plugins/GradleTypesafeConventions/src/main/kotlin/dev/ghostflyby/typesafeconventions/gradle/TypesafeConventionsTomlCatalogResolver.kt +++ b/plugins/GradleTypesafeConventions/src/main/kotlin/dev/ghostflyby/typesafeconventions/gradle/TypesafeConventionsTomlCatalogResolver.kt @@ -38,6 +38,8 @@ internal data class TypesafeConventionsTomlCatalogAlias( internal class TypesafeConventionsTomlCatalogAliasIndex private constructor( aliases: List, private val sectionOwners: Map, + private val sectionNameSegments: Map, + private val sectionsBySectionNameSegment: Map, ) { private val aliasesByKey = aliases.associateBy { alias -> alias.section to alias.normalizedAliasPath } private val aliasesByEntry = aliases.associateBy(TypesafeConventionsTomlCatalogAlias::entry) @@ -65,16 +67,49 @@ internal class TypesafeConventionsTomlCatalogAliasIndex private constructor( fun sectionOwner(section: TypesafeConventionsCatalogSection): PsiElement? = sectionOwners[section] + /** + * The key segment naming a section, in whichever shape the catalog declares it. Find Usages is invoked on + * this segment when the caret sits on a section name. + */ + fun sectionNameSegment(section: TypesafeConventionsCatalogSection): TomlKeySegment? = + sectionNameSegments[section] + + /** + * The section a key segment declares: a standard table header (`[bundles]`), the first segment of a + * top-level dotted key (`bundles.foo = ...`), or an inline table key (`bundles = { ... }`). + * Alias segments are not section names. + */ + fun sectionForSectionNameSegment(segment: TomlKeySegment): TypesafeConventionsCatalogSection? = + sectionsBySectionNameSegment[segment] + + /** + * The section a key owner declares. Only standard tables own their section directly; dotted keys and + * inline tables record the declaring key segment as the owner instead. + */ + fun sectionForSectionOwner(owner: PsiElement): TypesafeConventionsCatalogSection? = + sectionOwners.entries.firstOrNull { (_, sectionOwner) -> sectionOwner === owner }?.key + internal companion object { fun create(tomlFile: TomlFile): TypesafeConventionsTomlCatalogAliasIndex { val sectionOwners = linkedMapOf() + val sectionNameSegmentsBySection = linkedMapOf() + val sectionsBySectionNameSegment = linkedMapOf() + fun recordSectionNameSegment(segment: TomlKeySegment, section: TypesafeConventionsCatalogSection) { + sectionNameSegmentsBySection.putIfAbsent(section, segment) + sectionsBySectionNameSegment.putIfAbsent(segment, section) + } + val aliases = buildList { for (element in tomlFile.children) { if (element is TomlHeaderOwner) { - val section = element.header.key?.text.typesafeConventionsCatalogSection() + val headerKey = element.header.key + val section = headerKey?.text.typesafeConventionsCatalogSection() val owner = element as? TomlKeyValueOwner if (section != null && owner != null) { sectionOwners.putIfAbsent(section, owner) + headerKey?.segments?.singleOrNull()?.let { segment -> + recordSectionNameSegment(segment, section) + } owner.entries.forEach { entry -> addAlias(section, entry, entry.key.segments) } } } @@ -82,7 +117,9 @@ internal class TypesafeConventionsTomlCatalogAliasIndex private constructor( val segments = element.key.segments val section = segments.firstOrNull()?.name.typesafeConventionsCatalogSection() if (section != null && segments.size > 1) { - sectionOwners.putIfAbsent(section, segments.first()) + val sectionNameSegment = segments.first() + sectionOwners.putIfAbsent(section, sectionNameSegment) + recordSectionNameSegment(sectionNameSegment, section) addAlias(section, element, segments.drop(1)) } @@ -90,6 +127,9 @@ internal class TypesafeConventionsTomlCatalogAliasIndex private constructor( val inlineSection = element.key.text.typesafeConventionsCatalogSection() if (inlineTable != null && inlineSection != null) { sectionOwners.putIfAbsent(inlineSection, inlineTable) + segments.singleOrNull()?.let { segment -> + recordSectionNameSegment(segment, inlineSection) + } inlineTable.entries.forEach { entry -> addAlias(inlineSection, entry, entry.key.segments) } @@ -97,7 +137,12 @@ internal class TypesafeConventionsTomlCatalogAliasIndex private constructor( } } } - return TypesafeConventionsTomlCatalogAliasIndex(aliases, sectionOwners) + return TypesafeConventionsTomlCatalogAliasIndex( + aliases = aliases, + sectionOwners = sectionOwners, + sectionNameSegments = sectionNameSegmentsBySection, + sectionsBySectionNameSegment = sectionsBySectionNameSegment, + ) } private fun MutableList.addAlias( diff --git a/plugins/GradleTypesafeConventions/src/test/kotlin/dev/ghostflyby/typesafeconventions/gradle/GradleTypesafeConventionsSyncTest.kt b/plugins/GradleTypesafeConventions/src/test/kotlin/dev/ghostflyby/typesafeconventions/gradle/GradleTypesafeConventionsSyncTest.kt index 64febc27..87d34ff1 100644 --- a/plugins/GradleTypesafeConventions/src/test/kotlin/dev/ghostflyby/typesafeconventions/gradle/GradleTypesafeConventionsSyncTest.kt +++ b/plugins/GradleTypesafeConventions/src/test/kotlin/dev/ghostflyby/typesafeconventions/gradle/GradleTypesafeConventionsSyncTest.kt @@ -36,6 +36,7 @@ import com.intellij.platform.workspace.jps.entities.ModuleEntity import com.intellij.platform.workspace.storage.entities import com.intellij.psi.* import com.intellij.psi.search.GlobalSearchScope +import com.intellij.psi.search.PsiSearchHelper import com.intellij.psi.search.SearchRequestCollector import com.intellij.psi.search.SearchSession import com.intellij.psi.search.searches.ReferencesSearch @@ -139,6 +140,29 @@ internal data class CatalogAccessorInCatalogCase( override fun toString(): String = "${catalog.catalogName} ${accessor.name}" } +internal data class CatalogSectionCase( + val section: TypesafeConventionsCatalogSection, + val declarationPath: String, + val referenceText: String, +) { + override fun toString(): String = section.tomlName +} + +internal data class CatalogSectionInConventionBuildCase( + val catalog: VersionCatalogCase, + val conventionBuild: ConventionBuildCase, + val section: CatalogSectionCase, +) { + override fun toString(): String = "${conventionBuild.name}: ${catalog.catalogName} ${section.section.tomlName}" +} + +internal data class CatalogSectionInCatalogCase( + val catalog: VersionCatalogCase, + val section: CatalogSectionCase, +) { + override fun toString(): String = "${catalog.catalogName} ${section.section.tomlName}" +} + internal data class CatalogRenameCase( val name: String, val oldDeclarationPath: String, @@ -353,6 +377,198 @@ private class GradleTypesafeConventionsSyncedProject( ) } + suspend fun assertConventionBuildCatalogSectionGotoResolvesToTomlSectionOwner( + scriptPath: Path, + versionCatalog: VersionCatalogCase, + referenceText: String, + section: TypesafeConventionsCatalogSection, + expressionText: String, + ) { + val tomlFile = requirePsiFile(projectRoot.resolve(versionCatalog.catalogPath)) as TomlFile + val expectedSectionOwner = readAction { + requireNotNull(typesafeConventionsTomlCatalogAliasIndex(tomlFile).sectionOwner(section)) { + "Expected ${versionCatalog.catalogPath} to declare a ${section.tomlName} section" + } + } + val conventionBuildScript = requirePsiFile(scriptPath) + + val (resolvedTargets, resolvedTargetDescription) = readAction { + val (sourceElement, offset) = findElementAtText( + conventionBuildScript, + expressionText, + referenceText, + ) + val targets = resolveTargetsWithRegisteredGotoDeclarationHandlers(sourceElement, offset).orEmpty() + targets to targets.joinToString(prefix = "[", postfix = "]", transform = ::describeGotoTarget) + } + + assertTrue( + resolvedTargets.any { target -> target == expectedSectionOwner }, + "Expected $referenceText in $expressionText to resolve to the ${section.tomlName} TOML section. " + + "resolvedTargets=$resolvedTargetDescription " + + "${workspaceModelState()} ${moduleGradleState()}", + ) + } + + /** + * Finds every Kotlin usage of a TOML section name (`[bundles]`, `[versions]`, `[plugins]`), which appear + * as the section token of a catalog accessor (`libs.bundles.junit.bundle`). + */ + suspend fun assertKotlinCatalogSectionFindUsagesFindsConventionSources( + versionCatalog: VersionCatalogCase, + section: TypesafeConventionsCatalogSection, + expectedScriptPaths: List, + ) { + val sectionNameSegment = requireTomlCatalogSectionNameSegment(versionCatalog, section) + val references = readAction { + ReferencesSearch.search(sectionNameSegment, GlobalSearchScope.projectScope(project)).findAll() + } + val rawUsages = readAction { + references.mapNotNull { reference -> + val expression = reference.element as? KtDotQualifiedExpression ?: return@mapNotNull null + val path = expression.containingFile.virtualFile?.toNioPath() ?: return@mapNotNull null + path to expression.text + }.toSet() + } + val actualUsages = withContext(Dispatchers.IO) { + rawUsages.map { (path, text) -> path.toRealPath() to text }.toSet() + } + val expectedPrefix = "${versionCatalog.catalogName}.${section.tomlName}." + val expectedUsages = expectedScriptPaths.map { it.realPath() }.toSet() + + assertEquals( + expectedUsages, + actualUsages.map { (path, _) -> path }.toSet(), + "Expected Find Usages on the ${section.tomlName} section of ${versionCatalog.catalogPath} to find " + + "every convention source using ${versionCatalog.catalogName}.${section.tomlName}. " + + "usages=$actualUsages, references=${references.map { it.javaClass.name }}", + ) + assertTrue( + actualUsages.all { (_, text) -> text.startsWith(expectedPrefix) }, + "Expected every usage of the ${section.tomlName} section to use ${versionCatalog.catalogName}." + + "${section.tomlName}. usages=$actualUsages", + ) + } + + /** + * The default Find Usages pipeline (`FindUsagesHandlerFactory` plus the use-scope enlarger) rather than a + * bare `ReferencesSearch` call, so the section search scope is covered too. + */ + suspend fun assertKotlinCatalogSectionDefaultFindUsagesHandlerFindsConventionSources( + versionCatalog: VersionCatalogCase, + section: TypesafeConventionsCatalogSection, + expectedScriptPaths: List, + ) { + val sectionNameSegment = requireTomlCatalogSectionNameSegment(versionCatalog, section) + val expectedFiles = expectedScriptPaths.map { requirePsiFile(it).virtualFile }.toSet() + val (handlerName, searchScope, actualFiles) = readAction { + @Suppress("CAST_NEVER_SUCCEEDS") + val handler = (FindUsagesHandlerFactory.EP_NAME as ExtensionPointName) + .getExtensionList(project) + .firstNotNullOfOrNull { factory -> + if (factory.canFindUsages(sectionNameSegment)) { + factory.createFindUsagesHandler( + sectionNameSegment, + FindUsagesHandlerFactory.OperationMode.USAGES_WITH_DEFAULT_OPTIONS, + ) + } else { + null + } + } + ?: error("Expected a Find Usages handler for ${versionCatalog.catalogName}:${section.tomlName}") + val options = handler.getFindUsagesOptions(null) + val usageFiles = mutableSetOf() + handler.processElementUsages( + sectionNameSegment, + Processor { usage -> + usage.virtualFile?.let(usageFiles::add) + true + }, + options, + ) + Triple(handler.javaClass.name, options.searchScope.toString(), usageFiles.toSet()) + } + + assertTrue( + expectedFiles.all(actualFiles::contains), + "Expected the default Find Usages handler to include every convention source using " + + "${versionCatalog.catalogName}.${section.tomlName}. handler=$handlerName, scope=$searchScope, " + + "expected=${expectedFiles.map { it.path }}, actual=${actualFiles.map { it.path }}", + ) + } + + /** + * Batches section-name requests for several catalogs into one [SearchSession], which is how a single search + * can query more than one catalog over the same search word. Per-session occurrence deduplication must not let + * an occurrence claimed for one catalog silence the request that actually owns it. + */ + suspend fun assertKotlinCatalogSectionFindUsagesSurvivesSharedSearchSession( + searches: List>, + expectedScriptPaths: List, + ) { + val expectedFiles = expectedScriptPaths.map { requirePsiFile(it).virtualFile.path }.toSet() + val sectionNameSegments = searches.map { (catalog, section) -> + catalog.catalogName to requireTomlCatalogSectionNameSegment(catalog, section) + } + val (foundExpressions, actualFiles) = readAction { + // One collector, one session, one request per catalog: distinct searchers, shared deduplication state. + val collector = SearchRequestCollector(SearchSession(sectionNameSegments.first().second)) + val projectScope = GlobalSearchScope.projectScope(project) + sectionNameSegments.forEach { (_, segment) -> + ReferencesSearch.search( + ReferencesSearch.SearchParameters(segment, projectScope, false, collector), + ).findAll() + } + val expressions = mutableListOf() + val files = mutableSetOf() + PsiSearchHelper.getInstance(project).processRequests(collector) { reference -> + (reference.element as? KtDotQualifiedExpression) + ?.let { expression -> + expressions += expression.text + expression.containingFile.virtualFile?.path?.let(files::add) + } + true + } + expressions to files + } + + assertTrue( + expectedFiles.all(actualFiles::contains), + "Expected a shared search session batching several catalogs to still report usages in every " + + "convention source. expected=${expectedFiles.sorted()}, actual=${actualFiles.sorted()}, " + + "expressions=$foundExpressions", + ) + searches.forEach { (catalog, section) -> + val expectedPrefix = "${catalog.catalogName}.${section.tomlName}." + assertTrue( + foundExpressions.any { it.startsWith(expectedPrefix) }, + "Expected the shared session to report usages of ${catalog.catalogName}.${section.tomlName}. " + + "expressions=$foundExpressions", + ) + } + } + + suspend fun assertKotlinCatalogSectionFindUsagesIsolatedToTargetCatalog( + versionCatalog: VersionCatalogCase, + section: TypesafeConventionsCatalogSection, + foreignExpressionText: String, + ) { + val sectionNameSegment = requireTomlCatalogSectionNameSegment(versionCatalog, section) + val foundExpressions = readAction { + ReferencesSearch.search(sectionNameSegment, GlobalSearchScope.projectScope(project)) + .findAll() + .mapNotNull { reference -> + (reference.element as? KtDotQualifiedExpression)?.text + } + } + + assertFalse( + foreignExpressionText in foundExpressions, + "Expected Find Usages on the ${section.tomlName} section of ${versionCatalog.catalogPath} to stay in " + + "that catalog, but found $foreignExpressionText. found=$foundExpressions", + ) + } + suspend fun assertConventionBuildKotlinCatalogReferencesResolveToTomlSegments( scriptPath: Path, versionCatalog: VersionCatalogCase, @@ -1003,6 +1219,16 @@ private class GradleTypesafeConventionsSyncedProject( return ImaginaryEditor(project, document) } + private fun describeGotoTarget(target: PsiElement?): String { + target ?: return "none" + val file = target.containingFile ?: return target.javaClass.simpleName + val document = PsiDocumentManager.getInstance(target.project).getDocument(file) + val line = document?.getLineNumber(target.textRange.startOffset)?.plus(1) + val column = document?.getLineStartOffset(line?.minus(1) ?: 0) + ?.let { target.textRange.startOffset - it + 1 } + return "${file.virtualFile?.path}:$line:$column" + } + private fun findElementAtText( file: PsiFile, text: String, @@ -1097,6 +1323,22 @@ private class GradleTypesafeConventionsSyncedProject( } ?: error("Cannot find $declarationPath in ${tomlFile.virtualFile.url}") } + /** + * The TOML key segment naming a catalog section, which is what Find Usages is invoked on: the header of a + * standard table (`[bundles]`), an inline table key (`bundles = { ... }`), or the leading segment of a + * top-level dotted key (`bundles.foo = ...`). + */ + private suspend fun requireTomlCatalogSectionNameSegment( + versionCatalog: VersionCatalogCase, + section: TypesafeConventionsCatalogSection, + ): TomlKeySegment { + val tomlFile = requirePsiFile(projectRoot.resolve(versionCatalog.catalogPath)) as? TomlFile + ?: error("Expected ${versionCatalog.catalogPath} to be a TOML PSI file") + return readAction { + typesafeConventionsTomlCatalogAliasIndex(tomlFile).sectionNameSegment(section) + } ?: error("Cannot find the ${section.tomlName} section in ${versionCatalog.catalogPath}") + } + private suspend fun requireTomlCatalogKeySegment( versionCatalog: VersionCatalogCase, declarationPath: String, @@ -1253,6 +1495,89 @@ internal class KotlinDslGradleTypesafeConventionsSyncTest { } } + @ParameterizedTest(name = "{0} (section)") + @MethodSource("catalogSectionsInConventionBuildCases") + suspend fun `kotlin dsl catalog section names navigate to toml section`( + testCase: CatalogSectionInConventionBuildCase, + ) { + val projectRoot = projectPathFixture.get() + syncedProject.assertConventionBuildCatalogSectionGotoResolvesToTomlSectionOwner( + scriptPath = projectRoot.resolve(testCase.conventionBuild.scriptPath), + versionCatalog = testCase.catalog, + referenceText = testCase.section.referenceText, + section = testCase.section.section, + expressionText = "${testCase.catalog.catalogName}.${testCase.section.declarationPath}", + ) + } + + @ParameterizedTest(name = "{0} (section find usages)") + @MethodSource("catalogSectionsInCatalogCases") + suspend fun `kotlin dsl catalog section find usages finds convention sources`( + testCase: CatalogSectionInCatalogCase, + ) { + val projectRoot = projectPathFixture.get() + // Section tokens appear in the convention scripts, plus the `plugins` block of the builds declaring + // precompiled script plugins (only for the default catalog, which those blocks reference); the + // internal-extension sources use library aliases, which have no section token. + val expectedScriptPaths = kotlinDslConventionScriptPaths(projectRoot) + + if (testCase.section.section == TypesafeConventionsCatalogSection.PLUGINS && + testCase.catalog.catalogName == "libs" + ) { + kotlinDslPluginBlockScriptPaths(projectRoot) + } else { + emptyList() + } + syncedProject.assertKotlinCatalogSectionFindUsagesFindsConventionSources( + versionCatalog = testCase.catalog, + section = testCase.section.section, + expectedScriptPaths = expectedScriptPaths, + ) + } + + @Test + suspend fun `kotlin dsl catalog section find usages distinguishes catalogs`() { + projectPathFixture.get() + syncedProject.assertKotlinCatalogSectionFindUsagesIsolatedToTargetCatalog( + versionCatalog = versionCatalogCasesForTypesafeConventions().single { it.catalogName == "libs" }, + section = TypesafeConventionsCatalogSection.VERSIONS, + foreignExpressionText = "customLibs.versions.junit.jupiter", + ) + } + + @Test + suspend fun `kotlin dsl catalog section find usages survives a shared search session`() { + val projectRoot = projectPathFixture.get() + syncedProject.assertKotlinCatalogSectionFindUsagesSurvivesSharedSearchSession( + searches = versionCatalogCasesForTypesafeConventions().map { + it to TypesafeConventionsCatalogSection.VERSIONS + }, + expectedScriptPaths = kotlinDslConventionScriptPaths(projectRoot), + ) + } + + @ParameterizedTest(name = "{0} (section default handler)") + @MethodSource("catalogSectionsInCatalogCases") + suspend fun `kotlin default find usages handler finds section usages in convention sources`( + testCase: CatalogSectionInCatalogCase, + ) { + val projectRoot = projectPathFixture.get() + // The `plugins` section is also used from precompiled script `plugins` blocks, but only for the + // default catalog, which those blocks reference. + val expectedScriptPaths = kotlinDslConventionScriptPaths(projectRoot) + + if (testCase.section.section == TypesafeConventionsCatalogSection.PLUGINS && + testCase.catalog.catalogName == "libs" + ) { + kotlinDslPluginBlockScriptPaths(projectRoot) + } else { + emptyList() + } + syncedProject.assertKotlinCatalogSectionDefaultFindUsagesHandlerFindsConventionSources( + versionCatalog = testCase.catalog, + section = testCase.section.section, + expectedScriptPaths = expectedScriptPaths, + ) + } + @Test suspend fun `kotlin plugins block catalog accessor resolves and navigates to toml`() { val projectRoot = projectPathFixture.get() @@ -1583,6 +1908,21 @@ internal class KotlinDslGradleTypesafeConventionsSyncTest { } + fun catalogSectionsInConventionBuildCases(): List = + kotlinDslConventionBuildCases().flatMap { conventionBuild -> + versionCatalogCasesForTypesafeConventions().flatMap { catalog -> + catalogSectionCases().map { section -> + CatalogSectionInConventionBuildCase(catalog, conventionBuild, section) + } + } + } + + fun catalogSectionsInCatalogCases(): List = + versionCatalogCasesForTypesafeConventions().flatMap { catalog -> + catalogSectionCases().map { section -> CatalogSectionInCatalogCase(catalog, section) } + } + + fun catalogAccessorsInCatalogCases(): List = versionCatalogCasesForTypesafeConventions().flatMap { catalog -> catalogAccessorCases().map { accessor -> CatalogAccessorInCatalogCase(catalog, accessor) } @@ -1908,6 +2248,25 @@ private fun catalogAccessorCases(): List = ), ) +private fun catalogSectionCases(): List = + listOf( + CatalogSectionCase( + section = TypesafeConventionsCatalogSection.VERSIONS, + declarationPath = "versions", + referenceText = "versions", + ), + CatalogSectionCase( + section = TypesafeConventionsCatalogSection.BUNDLES, + declarationPath = "bundles", + referenceText = "bundles", + ), + CatalogSectionCase( + section = TypesafeConventionsCatalogSection.PLUGINS, + declarationPath = "plugins", + referenceText = "plugins", + ), + ) + private fun writeKotlinDslConventionBuild(buildRoot: Path, rootProjectName: String?) { buildRoot.createDirectories() buildRoot.resolve("settings.gradle.kts").writeText( diff --git a/plugins/GradleTypesafeConventions/src/test/kotlin/dev/ghostflyby/typesafeconventions/gradle/TypesafeConventionsTomlCatalogResolverTest.kt b/plugins/GradleTypesafeConventions/src/test/kotlin/dev/ghostflyby/typesafeconventions/gradle/TypesafeConventionsTomlCatalogResolverTest.kt index af361a39..7cdd59ea 100644 --- a/plugins/GradleTypesafeConventions/src/test/kotlin/dev/ghostflyby/typesafeconventions/gradle/TypesafeConventionsTomlCatalogResolverTest.kt +++ b/plugins/GradleTypesafeConventions/src/test/kotlin/dev/ghostflyby/typesafeconventions/gradle/TypesafeConventionsTomlCatalogResolverTest.kt @@ -11,6 +11,8 @@ import com.intellij.openapi.application.readAction import com.intellij.openapi.command.WriteCommandAction import com.intellij.psi.PsiDocumentManager import com.intellij.psi.PsiFileFactory +import com.intellij.psi.util.PsiTreeUtil +import com.intellij.psi.util.parentOfType import com.intellij.testFramework.junit5.TestApplication import com.intellij.testFramework.junit5.fixture.moduleFixture import com.intellij.testFramework.junit5.fixture.projectFixture @@ -24,6 +26,10 @@ import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test import org.toml.lang.psi.TomlFile import org.toml.lang.psi.TomlFileType +import org.toml.lang.psi.TomlInlineTable +import org.toml.lang.psi.TomlKeySegment +import org.toml.lang.psi.TomlKeyValue +import org.toml.lang.psi.TomlTable @TestApplication internal class TypesafeConventionsTomlCatalogResolverTest { @@ -41,6 +47,99 @@ internal class TypesafeConventionsTomlCatalogResolverTest { ) private val cachedTomlFile by cachedTomlFileFixture + @Test + suspend fun `indexes section name segments and section owners for every toml shape`() = readAction { + val standard = createTomlFile( + """ + [bundles] + junit-bundle = ["junit-jupiter"] + """.trimIndent(), + ) + val dotted = createTomlFile( + """ + bundles.junit-bundle = ["junit-jupiter"] + """.trimIndent(), + ) + val inline = createTomlFile( + """ + bundles = { junit-bundle = ["junit-jupiter"] } + """.trimIndent(), + ) + + assertEquals( + TypesafeConventionsCatalogSection.BUNDLES, + typesafeConventionsTomlCatalogAliasIndex(standard) + .sectionForSectionNameSegment(standard.singleSectionNameSegment()), + ) + assertEquals( + TypesafeConventionsCatalogSection.BUNDLES, + typesafeConventionsTomlCatalogAliasIndex(dotted) + .sectionForSectionNameSegment(dotted.singleSectionNameSegment()), + ) + assertEquals( + TypesafeConventionsCatalogSection.BUNDLES, + typesafeConventionsTomlCatalogAliasIndex(inline) + .sectionForSectionNameSegment(inline.singleSectionNameSegment()), + ) + + // Standard tables own their section; dotted keys and inline tables record the declaring key as owner, + // which is what the goto handler resolves a section token to. + val standardIndex = typesafeConventionsTomlCatalogAliasIndex(standard) + val standardTable = PsiTreeUtil.findChildOfType(standard, TomlTable::class.java) + assertEquals( + TypesafeConventionsCatalogSection.BUNDLES, + standardTable?.let(standardIndex::sectionForSectionOwner), + ) + val dottedIndex = typesafeConventionsTomlCatalogAliasIndex(dotted) + val dottedTable = PsiTreeUtil.findChildOfType(dotted, TomlInlineTable::class.java) + assertNull(dottedTable?.let(dottedIndex::sectionForSectionOwner)) + val inlineIndex = typesafeConventionsTomlCatalogAliasIndex(inline) + val inlineTable = PsiTreeUtil.findChildOfType(inline, TomlInlineTable::class.java) + assertEquals( + TypesafeConventionsCatalogSection.BUNDLES, + inlineTable?.let(inlineIndex::sectionForSectionOwner), + ) + } + + @Test + suspend fun `alias segments are not reported as section name segments`() = readAction { + val file = createTomlFile( + """ + [bundles] + junit-bundle = ["junit-jupiter"] + """.trimIndent(), + ) + val index = typesafeConventionsTomlCatalogAliasIndex(file) + val entry = requireNotNull(findCatalogEntry(file, "bundles.junit-bundle")) + val aliasSegment = entry.key.segments.single() + + assertNull(index.sectionForSectionNameSegment(aliasSegment)) + assertEquals( + TypesafeConventionsCatalogSection.BUNDLES, + index.sectionForSectionNameSegment(file.singleSectionNameSegment()), + ) + } + + @Test + suspend fun `a library alias named like a section is not a section name`() = readAction { + val file = createTomlFile( + """ + [libraries] + bundles = { module = "example:bundles", version = "1.0" } + """.trimIndent(), + ) + val index = typesafeConventionsTomlCatalogAliasIndex(file) + val alias = requireNotNull(index.find(TypesafeConventionsCatalogSection.LIBRARIES, "bundles")) + val aliasSegment = alias.segments.single() + + // The key is a normal library alias, and the file declares no bundles section, so nothing may claim + // that section for it: section navigation and section Find Usages resolve through these lookups. + assertEquals("bundles", aliasSegment.name) + assertNull(index.sectionForSectionNameSegment(aliasSegment)) + assertNull(index.sectionNameSegment(TypesafeConventionsCatalogSection.BUNDLES)) + assertNull(index.sectionOwner(TypesafeConventionsCatalogSection.BUNDLES)) + } + @Test suspend fun `resolves all version catalog sections`() = readAction { val file = createTomlFile( @@ -349,6 +448,27 @@ internal class TypesafeConventionsTomlCatalogResolverTest { PsiFileFactory.getInstance(project) .createFileFromText("libs.versions.toml", TomlFileType, text) as TomlFile + /** + * The `bundles` segment of a single-section catalog, in whichever shape the file declares it: a table + * header, an inline table key, or the leading segment of a top-level dotted key. + */ + private fun TomlFile.singleSectionNameSegment(): TomlKeySegment { + val headerSegment = PsiTreeUtil.findChildOfType(this, TomlTable::class.java) + ?.header + ?.key + ?.segments + ?.singleOrNull() + return PsiTreeUtil.findChildrenOfType(this, TomlKeySegment::class.java) + .single { segment -> + segment.name == "bundles" && + (segment == headerSegment || + segment.parentOfType(withSelf = false) + ?.key + ?.segments + ?.firstOrNull() == segment) + } + } + private fun findCatalogEntry(tomlFile: TomlFile, declarationPath: String) = findCatalogAlias(tomlFile, declarationPath)?.entry