From f2e7a376ac7ea5dfa5d916c9f97a7f64135b9630 Mon Sep 17 00:00:00 2001 From: atrifyllis Date: Fri, 28 Aug 2026 23:46:04 +0300 Subject: [PATCH] Read @UseCase through UAST so Kotlin tests are supported The four code-side extensions read annotations through the Java PSI model, so a @UseCase on a Kotlin test method is invisible to all of them: no gutter icon, no inspection, no Find Usages, and no spec-to-test navigation landing in real source. Switch them to UAST, the platform's language-neutral view of JVM annotations, and register the line marker and the inspection for the UAST meta-language instead of JAVA. One code path now serves Java, Kotlin and any other language with a UAST implementation, with no new runtime dependency: the Kotlin plugin is added to the build only so the tests have a Kotlin language to parse. The annotation type itself may now be declared in Kotlin as well - it is still looked up by short name through PsiShortNamesCache, which sees light classes. --- CLAUDE.md | 15 ++-- README.md | 27 +++++-- build.gradle.kts | 3 + .../tools/ij/UseCaseDeclarationProvider.kt | 62 +++++++++------ .../tools/ij/UseCaseIdInspection.kt | 50 +++++++----- .../unifiedprocess/tools/ij/UseCaseIndex.kt | 77 +++++++++++++++++-- .../ij/UseCaseToSpecLineMarkerProvider.kt | 62 ++++++++++----- .../tools/ij/UseCaseUsageSearcher.kt | 61 +++++++-------- src/main/resources/META-INF/plugin.xml | 11 ++- .../tools/ij/UseCaseIdInspectionTest.kt | 45 +++++++++++ .../tools/ij/UseCaseIndexTest.kt | 26 +++++++ .../ij/UseCaseToSpecLineMarkerProviderTest.kt | 59 ++++++++++++++ 12 files changed, 387 insertions(+), 111 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fc43af5..70e8b89 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project -IntelliJ IDEA plugin (Kotlin, JVM 21) that adds gutter-icon navigation between `@UseCase`-annotated Java test methods +IntelliJ IDEA plugin (Kotlin, JVM 21) that adds gutter-icon navigation between `@UseCase`-annotated test methods and their Markdown specs in AI Unified Process projects. Built with the JetBrains `org.jetbrains.intellij.platform` Gradle plugin. @@ -38,9 +38,12 @@ The plugin is three Kotlin files in `src/main/kotlin/ai/unifiedprocess/tools/ij/ style). Content match reads the file via `contentsToByteArray()` on every call — fine for typical AI Unified Process repos but a known scaling concern (see README "Notes"). -- **`UseCaseToSpecLineMarkerProvider`** (Java line marker): triggers only on the `PsiIdentifier` leaf of an annotation - reference (e.g. the `UseCase` token in `@UseCase(...)`), per IntelliJ's contract that markers must anchor to leaves. - Reads the `id` attribute as a `PsiLiteralExpression` and delegates lookup to `UseCaseIndex`. +- **`UseCaseToSpecLineMarkerProvider`** (UAST line marker): registered for the `UAST` meta-language, so one provider + serves Java, Kotlin and every other language with a UAST implementation. It triggers only on the leaf whose text is + the annotation's short name (e.g. the `UseCase` token in `@UseCase(...)`), per IntelliJ's contract that markers must + anchor to leaves — and that leaf must have a reference parent, which is what tells the name token apart from a Kotlin + string value reading `"UseCase"`. Attributes are read as `UAnnotation` values via `UseCaseIndex`, never as + `PsiLiteralExpression`: the Java model does not cover other languages. - **`SpecToUseCaseLineMarkerProvider`** (Markdown line marker): Markdown PSI is unstable across IDE versions, so this provider does **plain-text regex matching on leaf elements** rather than navigating the AST. Recognised sites: @@ -53,8 +56,8 @@ The plugin is three Kotlin files in `src/main/kotlin/ai/unifiedprocess/tools/ij/ The plugin assumes the host project follows this shape (from the `aiup-petclinic` example): -- Java annotation named `UseCase` (annotation type) with attributes `id: String`, `scenario: String`, - `businessRules: String[]`. +- Annotation type named `UseCase` — Java or Kotlin — with attributes `id: String`, `scenario: String`, + `businessRules: String[]`. Tests carrying it may be written in any language the IDE exposes through UAST. - Use Case IDs are `UC-XXX` or the `SUC-XXX` / `BUC-XXX` variants (System / Business Use Case). - Markdown specs anywhere in the project content scope, identified by filename (`UC-XXX-*.md` or `UC-XXX_*.md`, `SUC-*`/`BUC-*` variants, optionally behind a project prefix such as `petclinic-UC-XXX-*.md`), a body line diff --git a/README.md b/README.md index 929b96c..708bde6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # AI Unified Process Navigator -IntelliJ plugin to navigate between `@UseCase`-annotated Java test methods and their Markdown specs in +IntelliJ plugin to navigate between `@UseCase`-annotated test methods — Java, Kotlin, or any JVM +language the IDE reads through UAST — and their Markdown specs in [AI Unified Process](https://unifiedprocess.ai) projects — with a live activity diagram of the Use Case spec you are editing. @@ -8,8 +9,9 @@ Use Case spec you are editing. ## Setup -The plugin requires the host project to define a Java annotation type named `UseCase`. It is looked up by short name, so -any package works. The canonical shape is: +The plugin requires the host project to define an annotation type named `UseCase`. It is looked up by short name, so +any package works — and either language will do, since the lookup goes through the IDE's short-name index. The canonical +shape is: ```java @Target(ElementType.METHOD) @@ -24,6 +26,19 @@ public @interface UseCase { } ``` +or the same thing as a Kotlin `annotation class`: + +```kotlin +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +@MustBeDocumented +annotation class UseCase( + val id: String, + val scenario: String = "Main Success Scenario", + val businessRules: Array = [], +) +``` + When the plugin opens a project that contains Markdown Use Case specs but no `UseCase` annotation type, it shows a one-time balloon notification with a **Create `UseCase.java`** action: pick a source root and the file is scaffolded for you. @@ -48,10 +63,10 @@ kept out of the step's diagram node. ### Gutter icons -In Java: +In test code (Java, Kotlin, or any other language with a UAST implementation): * `@UseCase(id = "UC-XXX")` jumps to the matching spec file, landing on the scenario heading and any business - rule headings referenced via `businessRules = {...}`. + rule headings referenced via `businessRules = {...}` (`[...]` in Kotlin). In Markdown specs: @@ -119,7 +134,7 @@ matched three ways: * **By body line** — a `**Use Case ID:** UC-XXX` declaration anywhere in the file. * **By title** — an H1 starting with the ID, e.g. `# UC-001: Kunde suchen` (used when no body line exists). -See [Setup](#setup) above for the matching Java annotation shape. +See [Setup](#setup) above for the matching annotation shape. ## Build diff --git a/build.gradle.kts b/build.gradle.kts index f6d20e8..5ce1a5d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -21,6 +21,9 @@ dependencies { // Bundled plugins we need bundledPlugin("com.intellij.java") bundledPlugin("org.intellij.plugins.markdown") + // Not used by the plugin's own code, which reads annotations through UAST: this is what + // gives the tests a Kotlin language to parse, and the sandbox IDE one to try it in. + bundledPlugin("org.jetbrains.kotlin") pluginVerifier() zipSigner() diff --git a/src/main/kotlin/ai/unifiedprocess/tools/ij/UseCaseDeclarationProvider.kt b/src/main/kotlin/ai/unifiedprocess/tools/ij/UseCaseDeclarationProvider.kt index 5192fd3..138850a 100644 --- a/src/main/kotlin/ai/unifiedprocess/tools/ij/UseCaseDeclarationProvider.kt +++ b/src/main/kotlin/ai/unifiedprocess/tools/ij/UseCaseDeclarationProvider.kt @@ -5,13 +5,16 @@ import com.intellij.model.psi.PsiSymbolDeclaration import com.intellij.model.psi.PsiSymbolDeclarationProvider import com.intellij.openapi.util.TextRange import com.intellij.psi.* -import com.intellij.psi.util.PsiTreeUtil +import org.jetbrains.uast.UAnnotation +import org.jetbrains.uast.ULiteralExpression +import org.jetbrains.uast.getParentOfType +import org.jetbrains.uast.toUElementOfType /** * Declares `UseCaseSymbol` / `BusinessRuleSymbol` / `ScenarioSymbol` at every * AI Unified Process-relevant site so Alt+F7 can resolve a target there. * - * Java sites: + * Annotation sites (any language UAST covers — Java, Kotlin, …): * - `@UseCase(id = "UC-XXX")` literal -> UseCaseSymbol * - `@UseCase(scenario = "A1: ...")` literal -> ScenarioSymbol * - `@UseCase(businessRules = {"BR-XXX"})` literal -> BusinessRuleSymbol @@ -30,34 +33,51 @@ class UseCaseDeclarationProvider : PsiSymbolDeclarationProvider { element: PsiElement, offsetInElement: Int, ): Collection { - javaDeclaration(element)?.let { return listOf(it) } + annotationDeclaration(element)?.let { return listOf(it) } return markdownDeclarations(element, offsetInElement) } - private fun javaDeclaration(element: PsiElement): PsiSymbolDeclaration? { - val literal = element as? PsiLiteralExpression ?: return null - val value = literal.value as? String ?: return null + private fun annotationDeclaration(element: PsiElement): PsiSymbolDeclaration? { + val literal = element.toUElementOfType() ?: return null + // Declare on the element that owns the string and no other: the platform offers every + // ancestor of the caret, and each language nests its literals differently. + if (literal.sourcePsi !== element) return null + val value = literal.evaluate() as? String ?: return null - val pair = PsiTreeUtil.getParentOfType(literal, PsiNameValuePair::class.java) ?: return null - val ann = PsiTreeUtil.getParentOfType(pair, PsiAnnotation::class.java) ?: return null - if (!isUseCaseAnnotation(ann)) return null + val annotation = literal.getParentOfType() ?: return null + if (!UseCaseIndex.isUseCaseAnnotation(annotation)) return null - val ucId = (ann.findAttributeValue("id") as? PsiLiteralExpression)?.value as? String - ?: return null + val ucId = UseCaseIndex.attributeString(annotation, "id") ?: return null val project = element.project - val symbol: Symbol = when (pair.name) { + val symbol: Symbol = when (annotation.attributeNameOf(literal)) { "id" -> UseCaseSymbol(project, ucId) "businessRules" -> BusinessRuleSymbol(project, ucId, value) "scenario" -> ScenarioSymbol(project, ucId, scenarioPrefix(value)) else -> return null } - // Range inside the literal that excludes the surrounding quotes. - val length = literal.textLength - if (length < 2) return null - val rangeInElement = TextRange(1, length - 1) - return SimpleDeclaration(literal, rangeInElement, symbol) + val rangeInElement = element.rangeInsideQuotes() ?: return null + return SimpleDeclaration(element, rangeInElement, symbol) + } + + /** + * Which attribute a value was written for, decided by source position rather than by identity: + * UAST rebuilds its elements per conversion, so the same literal is not the same instance twice. + */ + private fun UAnnotation.attributeNameOf(value: ULiteralExpression): String? { + val target = value.sourcePsi?.textRange ?: return null + return attributeValues.firstOrNull { attribute -> + attribute.expression.sourcePsi?.textRange?.contains(target) == true + }?.name + } + + /** The range inside a string literal that excludes its quotes, whichever quoting the language uses. */ + private fun PsiElement.rangeInsideQuotes(): TextRange? { + val text = text ?: return null + val quote = QUOTE_FORMS.firstOrNull { text.length >= 2 * it.length && text.startsWith(it) && text.endsWith(it) } + ?: return null + return TextRange(quote.length, text.length - quote.length) } private fun markdownDeclarations( @@ -129,14 +149,12 @@ class UseCaseDeclarationProvider : PsiSymbolDeclarationProvider { return listOf(SimpleDeclaration(element, rangeInElement, symbol)) } - private fun isUseCaseAnnotation(ann: PsiAnnotation): Boolean { - val qn = ann.qualifiedName ?: return false - return qn == "UseCase" || qn.endsWith(".UseCase") - } - private fun scenarioPrefix(scenario: String): String? = UseCaseIndex.scenarioPrefix(scenario) private companion object { + // Longest first: a Kotlin raw string starts with the one-character form too. + val QUOTE_FORMS = listOf("\"\"\"", "\"") + val USE_CASE_ID_LINE = UseCaseIndex.USE_CASE_ID_LINE val BR_HEADING = UseCaseIndex.BUSINESS_RULE_SITE val ALT_FLOW_HEADING = UseCaseIndex.ALT_FLOW_HEADING diff --git a/src/main/kotlin/ai/unifiedprocess/tools/ij/UseCaseIdInspection.kt b/src/main/kotlin/ai/unifiedprocess/tools/ij/UseCaseIdInspection.kt index f1dac1a..c6ef866 100644 --- a/src/main/kotlin/ai/unifiedprocess/tools/ij/UseCaseIdInspection.kt +++ b/src/main/kotlin/ai/unifiedprocess/tools/ij/UseCaseIdInspection.kt @@ -1,28 +1,40 @@ package ai.unifiedprocess.tools.ij -import com.intellij.codeInspection.AbstractBaseJavaLocalInspectionTool +import com.intellij.codeInspection.AbstractBaseUastLocalInspectionTool import com.intellij.codeInspection.ProblemsHolder -import com.intellij.psi.JavaElementVisitor -import com.intellij.psi.PsiAnnotation import com.intellij.psi.PsiElementVisitor -import com.intellij.psi.PsiLiteralExpression +import com.intellij.uast.UastHintedVisitorAdapter +import org.jetbrains.uast.UAnnotation +import org.jetbrains.uast.visitor.AbstractUastNonRecursiveVisitor -class UseCaseIdInspection : AbstractBaseJavaLocalInspectionTool() { +/** + * Flags a `@UseCase(id = ...)` whose ID has no spec file in the project — in Java, Kotlin, or any + * other language UAST covers. + */ +class UseCaseIdInspection : AbstractBaseUastLocalInspectionTool() { override fun buildVisitor(holder: ProblemsHolder, isOnTheFly: Boolean): PsiElementVisitor = - object : JavaElementVisitor() { - override fun visitAnnotation(annotation: PsiAnnotation) { - // Match by short name so the inspection works regardless of the - // annotation's package — same convention as UseCaseIndex. - if (annotation.nameReferenceElement?.referenceName != "UseCase") return - val idLiteral = annotation.findAttributeValue("id") as? PsiLiteralExpression ?: return - val id = idLiteral.value as? String ?: return - if (UseCaseIndex.findSpecFiles(holder.project, id).isEmpty()) { - holder.registerProblem( - idLiteral, - "Use Case ID '$id' has no matching spec file in this project", - ) + UastHintedVisitorAdapter.create( + holder.file.language, + object : AbstractUastNonRecursiveVisitor() { + override fun visitAnnotation(node: UAnnotation): Boolean { + // Matched by short name so the inspection works regardless of the + // annotation's package — same convention as UseCaseIndex. + if (!UseCaseIndex.isUseCaseAnnotation(node)) return true + val idExpression = node.findAttributeValue("id") ?: return true + val id = idExpression.evaluate() as? String ?: return true + // The warning belongs on the id as written, which is why the source element is + // used rather than the UAST node: only the former exists in the editor. + val anchor = idExpression.sourcePsi ?: return true + if (UseCaseIndex.findSpecFiles(holder.project, id).isEmpty()) { + holder.registerProblem( + anchor, + "Use Case ID '$id' has no matching spec file in this project", + ) + } + return true } - } - } + }, + arrayOf(UAnnotation::class.java), + ) } diff --git a/src/main/kotlin/ai/unifiedprocess/tools/ij/UseCaseIndex.kt b/src/main/kotlin/ai/unifiedprocess/tools/ij/UseCaseIndex.kt index 9158e56..860dfcb 100644 --- a/src/main/kotlin/ai/unifiedprocess/tools/ij/UseCaseIndex.kt +++ b/src/main/kotlin/ai/unifiedprocess/tools/ij/UseCaseIndex.kt @@ -6,6 +6,11 @@ import com.intellij.openapi.vfs.VirtualFile import com.intellij.psi.* import com.intellij.psi.search.GlobalSearchScope import com.intellij.psi.search.searches.AnnotatedElementsSearch +import org.jetbrains.uast.UAnnotation +import org.jetbrains.uast.UCallExpression +import org.jetbrains.uast.UExpression +import org.jetbrains.uast.UExpressionList +import org.jetbrains.uast.toUElementOfType /** * Helpers to find Use Case specs and tests for a given ID. @@ -22,6 +27,12 @@ import com.intellij.psi.search.searches.AnnotatedElementsSearch */ object UseCaseIndex { + /** Short name of the annotation the host project declares (see the README's Setup section). */ + const val ANNOTATION_NAME = "UseCase" + + /** The name as written at an annotation site, with any package qualifier dropped. */ + private val ANNOTATION_REFERENCE = Regex("""^@\s*(?:\w+\s*\.\s*)*(\w+)""") + /** * Matches the `**Use Case ID:** UC-XXX` declaration line; shared by every * component that parses spec bodies so the accepted ID shapes stay in sync. @@ -240,11 +251,22 @@ object UseCaseIndex { * "points at": the scenario heading (Main Success Scenario or `### A1:`), * plus one leaf per business rule heading. */ - fun findSpecLeavesForAnnotation(project: Project, annotation: PsiAnnotation): List { - val useCaseId = getStringAttribute(annotation, "id") ?: return emptyList() - val scenario = getStringAttribute(annotation, "scenario") - val brIds = getStringArrayAttribute(annotation, "businessRules") + fun findSpecLeavesForAnnotation(project: Project, annotation: UAnnotation): List { + val useCaseId = attributeString(annotation, "id") ?: return emptyList() + return findSpecLeaves( + project, + useCaseId, + scenario = attributeString(annotation, "scenario"), + brIds = attributeStrings(annotation, "businessRules"), + ) + } + fun findSpecLeaves( + project: Project, + useCaseId: String, + scenario: String?, + brIds: List, + ): List { val scenarioCode = scenario ?.takeIf { it.isNotBlank() && !isMainScenarioLabel(it) } ?.let { scenarioPrefix(it) } @@ -319,12 +341,49 @@ object UseCaseIndex { return null } + /** + * Recognises the annotation by short name, the same convention + * [findUseCaseAnnotationClass] uses — so `@UseCase` is understood wherever it is declared, in + * any language with a UAST implementation. + */ + fun isUseCaseAnnotation(annotation: UAnnotation): Boolean { + val qualified = annotation.qualifiedName + if (qualified != null) { + return qualified == ANNOTATION_NAME || qualified.endsWith(".$ANNOTATION_NAME") + } + // Unresolved: the annotation type is not on the module's classpath. Reading the name as + // written keeps the inspection working in the project that has yet to declare it. + val written = annotation.sourcePsi?.text ?: return false + return ANNOTATION_REFERENCE.find(written)?.groupValues?.get(1) == ANNOTATION_NAME + } + + /** Value of a single-string attribute (`id`, `scenario`), or null when it is absent. */ + fun attributeString(annotation: UAnnotation, name: String): String? = + annotation.findAttributeValue(name)?.evaluate() as? String + + /** Values of a string-array attribute (`businessRules`), which may also be written bare. */ + fun attributeStrings(annotation: UAnnotation, name: String): List { + val value = annotation.findAttributeValue(name) ?: return emptyList() + return arrayElements(value).mapNotNull { it.evaluate() as? String } + } + + /** + * The elements of an array-valued attribute. Java writes one as `{...}` and Kotlin as `[...]`, + * which UAST models as a call and an expression list respectively; a bare value counts as an + * array of one, since `businessRules = "BR-001"` means the same as `{"BR-001"}`. + */ + fun arrayElements(value: UExpression): List = when (value) { + is UCallExpression -> value.valueArguments + is UExpressionList -> value.expressions + else -> listOf(value) + } + private fun findUseCaseAnnotationClass(project: Project): com.intellij.psi.PsiClass? { val scope = GlobalSearchScope.allScope(project) // Annotation lives in a project-specific package, so we look it up // by its short name. We require it to be an annotation type. val classes = com.intellij.psi.search.PsiShortNamesCache.getInstance(project) - .getClassesByName("UseCase", scope) + .getClassesByName(ANNOTATION_NAME, scope) return classes.firstOrNull { it.isAnnotationType } } @@ -333,6 +392,9 @@ object UseCaseIndex { if (value is PsiLiteralExpression) { return value.value as? String } + // A test written in another UAST language arrives here as a light annotation whose values + // are not Java literals; UAST evaluates them without this having to know the language. + (value.toUElementOfType()?.evaluate() as? String)?.let { return it } // For computed expressions, fall back to text without quotes return value.text?.trim('"') } @@ -350,6 +412,11 @@ object UseCaseIndex { if (value is PsiLiteralExpression) { return listOfNotNull(value.value as? String) } + // Same light-annotation case as getStringAttribute: read the array through UAST. + value.toUElementOfType()?.let { uValue -> + (uValue.evaluate() as? String)?.let { return listOf(it) } + return arrayElements(uValue).mapNotNull { it.evaluate() as? String } + } return emptyList() } } diff --git a/src/main/kotlin/ai/unifiedprocess/tools/ij/UseCaseToSpecLineMarkerProvider.kt b/src/main/kotlin/ai/unifiedprocess/tools/ij/UseCaseToSpecLineMarkerProvider.kt index a1e4980..5d09fda 100644 --- a/src/main/kotlin/ai/unifiedprocess/tools/ij/UseCaseToSpecLineMarkerProvider.kt +++ b/src/main/kotlin/ai/unifiedprocess/tools/ij/UseCaseToSpecLineMarkerProvider.kt @@ -7,41 +7,30 @@ import com.intellij.codeInsight.navigation.impl.PsiTargetPresentationRenderer import com.intellij.icons.AllIcons import com.intellij.openapi.editor.markup.GutterIconRenderer import com.intellij.openapi.util.TextRange -import com.intellij.psi.PsiAnnotation import com.intellij.psi.PsiDocumentManager import com.intellij.psi.PsiElement import com.intellij.psi.PsiFile -import com.intellij.psi.PsiIdentifier -import com.intellij.psi.PsiJavaCodeReferenceElement -import com.intellij.psi.PsiLiteralExpression import com.intellij.psi.PsiManager +import com.intellij.psi.PsiReference import java.util.function.Supplier +import org.jetbrains.uast.UAnnotation +import org.jetbrains.uast.toUElementOfType /** * Adds a gutter icon next to `@UseCase` annotations on test methods. * Clicking it navigates to the matching Markdown spec file. * - * The marker is placed on a leaf element (the annotation name identifier) - * to comply with IntelliJ's LineMarkerProvider contract. + * Registered for the UAST meta-language rather than for Java, so one code path serves every + * language with a UAST implementation — a Kotlin test carries the same annotation and gets the + * same icon. The marker is placed on a leaf element (the annotation name token) to comply with + * IntelliJ's LineMarkerProvider contract. */ class UseCaseToSpecLineMarkerProvider : LineMarkerProvider { override fun getLineMarkerInfo(element: PsiElement): LineMarkerInfo<*>? { - // Only react on the leaf identifier of an annotation reference, - // e.g. the "UseCase" token inside `@UseCase(...)`. - if (element !is PsiIdentifier) return null - - val refElement = element.parent as? PsiJavaCodeReferenceElement ?: return null - val annotation = refElement.parent as? PsiAnnotation ?: return null - - if (annotation.qualifiedName?.endsWith(".UseCase") != true && - annotation.qualifiedName != "UseCase") { - return null - } - - val idValue = annotation.findAttributeValue("id") as? PsiLiteralExpression ?: return null - val useCaseId = idValue.value as? String ?: return null + val annotation = element.useCaseAnnotationAtName() ?: return null + val useCaseId = UseCaseIndex.attributeString(annotation, "id") ?: return null val project = element.project // Prefer specific lines in the spec (scenario heading + BR headings). @@ -66,6 +55,32 @@ class UseCaseToSpecLineMarkerProvider : LineMarkerProvider { return builder.createLineMarkerInfo(element) } + /** + * The `@UseCase` annotation this leaf names, or null for any other leaf. + * + * Anchoring on the name token is what keeps the marker to one per annotation: the short name + * appears exactly once at each site, in every language. Requiring the token's parent to be a + * reference rules out a string argument that happens to read the same — in Kotlin the contents + * of `"UseCase"` are a leaf whose text matches too. + */ + private fun PsiElement.useCaseAnnotationAtName(): UAnnotation? { + if (firstChild != null) return null + if (text != UseCaseIndex.ANNOTATION_NAME) return null + val named = parent ?: return null + if (named !is PsiReference && named.reference == null) return null + + var candidate: PsiElement? = named + repeat(MAX_NAME_DEPTH) { + if (candidate == null || candidate is PsiFile) return null + val annotation = candidate.toUElementOfType() + if (annotation != null) { + return annotation.takeIf { UseCaseIndex.isUseCaseAnnotation(it) } + } + candidate = candidate.parent + } + return null + } + private object SpecTargetRenderer : PsiTargetPresentationRenderer() { override fun getElementText(element: PsiElement): String { val file = element.containingFile ?: return element.text.orEmpty() @@ -87,4 +102,11 @@ class UseCaseToSpecLineMarkerProvider : LineMarkerProvider { return stripped.takeIf { it.isNotEmpty() } } } + + private companion object { + // How far above the name token the annotation sits: two levels in Java, five in Kotlin, + // whose name is wrapped in a type reference and a constructor callee on the way up. The + // cap only stops a runaway walk — the text and reference checks are what select the token. + const val MAX_NAME_DEPTH = 10 + } } diff --git a/src/main/kotlin/ai/unifiedprocess/tools/ij/UseCaseUsageSearcher.kt b/src/main/kotlin/ai/unifiedprocess/tools/ij/UseCaseUsageSearcher.kt index a43d152..c802866 100644 --- a/src/main/kotlin/ai/unifiedprocess/tools/ij/UseCaseUsageSearcher.kt +++ b/src/main/kotlin/ai/unifiedprocess/tools/ij/UseCaseUsageSearcher.kt @@ -11,10 +11,13 @@ import com.intellij.psi.* import com.intellij.psi.search.GlobalSearchScope import com.intellij.psi.search.PsiShortNamesCache import com.intellij.psi.search.searches.AnnotatedElementsSearch -import com.intellij.psi.util.PsiTreeUtil import com.intellij.util.AbstractQuery import com.intellij.util.Processor import com.intellij.util.Query +import org.jetbrains.uast.UAnchorOwner +import org.jetbrains.uast.UAnnotation +import org.jetbrains.uast.UMethod +import org.jetbrains.uast.toUElementOfType /** * Returns every Java/Markdown site that participates in the same Use Case @@ -50,7 +53,7 @@ class UseCaseUsageSearcher : UsageSearcher { private fun collectUsages(project: Project, target: UseCaseRelatedSymbol): List = buildList { - addAll(javaUsages(project, target)) + addAll(annotationUsages(project, target)) addAll(markdownUsages(project, target)) } @@ -71,52 +74,52 @@ class UseCaseUsageSearcher : UsageSearcher { } } - private fun javaUsages(project: Project, target: UseCaseRelatedSymbol): List { + private fun annotationUsages(project: Project, target: UseCaseRelatedSymbol): List { val annotationClass = findUseCaseAnnotationClass(project) ?: return emptyList() - val fqn = annotationClass.qualifiedName ?: return emptyList() val annotated = AnnotatedElementsSearch .searchPsiMethods(annotationClass, GlobalSearchScope.projectScope(project)) .findAll() val result = mutableListOf() for (method in annotated) { - val ann = method.getAnnotation(fqn) ?: continue - val ucId = stringAttr(ann, "id") ?: continue + // The search answers in Java terms, which for a test written in another language is a + // light method whose ranges point at nothing the author can see. UAST leads back to the + // annotation as written, so every usage below lands in real source. + val uMethod = method.toUElementOfType() ?: continue + val annotation = uMethod.uAnnotations + .firstOrNull { UseCaseIndex.isUseCaseAnnotation(it) } ?: continue + val ucId = UseCaseIndex.attributeString(annotation, "id") ?: continue if (ucId != target.useCaseId) continue - collectAnnotationLiterals(ann, target, result) + collectAnnotationValues(annotation, target, result) } return result } - private fun collectAnnotationLiterals( - ann: PsiAnnotation, + private fun collectAnnotationValues( + annotation: UAnnotation, target: UseCaseRelatedSymbol, out: MutableList, ) { var sawScenarioAttr = false - for (pair in ann.parameterList.attributes) { - when (pair.name ?: "value") { + for (attribute in annotation.attributeValues) { + when (attribute.name ?: "value") { "id" -> if (target is UseCaseSymbol) { - (pair.value as? PsiLiteralExpression)?.let { out += it.asValueUsage() } + attribute.expression.sourcePsi?.let { out += it.asValueUsage() } } "scenario" -> { sawScenarioAttr = true if (target is ScenarioSymbol) { - val literal = pair.value as? PsiLiteralExpression ?: continue - val scenario = literal.value as? String + val scenario = attribute.expression.evaluate() as? String val code = scenario?.let(::extractScenarioCode) - if (code == target.scenarioCode) out += literal.asValueUsage() + if (code == target.scenarioCode) { + attribute.expression.sourcePsi?.let { out += it.asValueUsage() } + } } } "businessRules" -> if (target is BusinessRuleSymbol) { - val value = pair.value ?: continue - val literals = when (value) { - is PsiLiteralExpression -> listOf(value) - else -> PsiTreeUtil.findChildrenOfType(value, PsiLiteralExpression::class.java).toList() - } - for (literal in literals) { - val br = literal.value as? String ?: continue - if (br == target.brId) out += literal.asValueUsage() + for (element in UseCaseIndex.arrayElements(attribute.expression)) { + if (element.evaluate() as? String != target.brId) continue + element.sourcePsi?.let { out += it.asValueUsage() } } } } @@ -126,9 +129,8 @@ class UseCaseUsageSearcher : UsageSearcher { // attribute defaults to the main scenario, so anchor the usage on the // annotation's `UseCase` identifier (no literal exists to point at). if (target is ScenarioSymbol && target.scenarioCode == null && !sawScenarioAttr) { - ann.nameReferenceElement?.let { ref -> - out += PsiUsage.textUsage(ref.containingFile, ref.textRange) - } + val anchor = (annotation as? UAnchorOwner)?.uastAnchor?.sourcePsi ?: annotation.sourcePsi + anchor?.let { out += PsiUsage.textUsage(it.containingFile, it.textRange) } } } @@ -207,16 +209,15 @@ class UseCaseUsageSearcher : UsageSearcher { } } - private fun PsiLiteralExpression.asValueUsage(): Usage { + // The element a value was written as, whatever the language: a Java literal or a Kotlin string + // template, both of which carry their quotes in the text range. + private fun PsiElement.asValueUsage(): Usage { val r = textRange // Skip the surrounding quotes. val rangeForUsage = if (r.length >= 2) TextRange(r.startOffset + 1, r.endOffset - 1) else r return PsiUsage.textUsage(containingFile, rangeForUsage) } - private fun stringAttr(annotation: PsiAnnotation, name: String): String? = - ((annotation.findAttributeValue(name) as? PsiLiteralExpression)?.value) as? String - private fun extractScenarioCode(scenario: String): String? { if (scenario.isBlank() || UseCaseIndex.isMainScenarioLabel(scenario)) { return null diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index a9d8c58..8cd87be 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -23,7 +23,10 @@
  • Gutter icon on ### BR-XXX business rule headers jumps to tests referencing that rule
  • Find Usages (Alt+F7) works in both directions, and an inspection flags Use Case IDs without a spec
  • -

    Setup: the host project must define a Java annotation type called UseCase +

    Tests may be written in Java or Kotlin — the annotation is read through UAST, so any JVM + language the IDE supports works the same way.

    + +

    Setup: the host project must define an annotation type called UseCase with attributes id, scenario, and businessRules. If the plugin sees Markdown specs in a project but no such annotation, it offers a one-time balloon with a "Create UseCase.java" action that scaffolds the file into a chosen source root.

    @@ -40,8 +43,10 @@ + test direction goes through the Java view of the project, which for a Kotlin test + // is a light method. This pins that the annotation's values can still be read from there. + fun testFindTestMethodsResolvesKotlinAnnotations() { + myFixture.addFileToProject( + "src/test/kotlin/example/PetTest.kt", + """ + package example + + import ai.unifiedprocess.tools.UseCase + + class PetTest { + @UseCase(id = "UC-001", businessRules = ["BR-001"]) + fun main() {} + + @UseCase(id = "UC-002") + fun other() {} + } + """.trimIndent(), + ) + + val methods = UseCaseIndex.findTestMethods(project, "UC-001") + assertEquals(1, methods.size) + assertEquals("main", methods[0].name) + assertEquals(1, UseCaseIndex.findTestMethodsForBusinessRule(project, "UC-001", "BR-001").size) + } + fun testFindTestMethodsForBusinessRule() { myFixture.addFileToProject( "src/test/java/example/RuleTest.java", diff --git a/src/test/kotlin/ai/unifiedprocess/tools/ij/UseCaseToSpecLineMarkerProviderTest.kt b/src/test/kotlin/ai/unifiedprocess/tools/ij/UseCaseToSpecLineMarkerProviderTest.kt index 84c2a69..c403b42 100644 --- a/src/test/kotlin/ai/unifiedprocess/tools/ij/UseCaseToSpecLineMarkerProviderTest.kt +++ b/src/test/kotlin/ai/unifiedprocess/tools/ij/UseCaseToSpecLineMarkerProviderTest.kt @@ -29,6 +29,65 @@ class UseCaseToSpecLineMarkerProviderTest : UnifiedProcessTestBase() { ) } + fun testGutterAppearsOnKotlinUseCaseAnnotation() { + myFixture.addFileToProject( + "docs/UC-001-greeting.md", + "# Greeting\n\n**Use Case ID:** UC-001\n", + ) + val testFile = myFixture.addFileToProject( + "src/test/kotlin/example/PetTest.kt", + """ + package example + + import ai.unifiedprocess.tools.UseCase + + class PetTest { + @UseCase(id = "UC-001") + fun greet() {} + } + """.trimIndent(), + ) + myFixture.configureFromExistingVirtualFile(testFile.virtualFile) + + val tooltips = myFixture.findAllGutters().tooltips() + assertEquals( + "expected exactly one 'Go to spec for UC-001' gutter, got $tooltips", + 1, + tooltips.count { it.contains("Go to spec for UC-001") }, + ) + } + + // The Kotlin form of a string is a template whose contents are a leaf of their own, so a value + // reading "UseCase" looks just like the annotation's name token. Only the name token may carry + // the marker, or the annotation would be marked twice. + fun testKotlinStringValueMatchingTheAnnotationNameIsNotMarked() { + myFixture.addFileToProject( + "docs/UC-001-greeting.md", + "# Greeting\n\n**Use Case ID:** UC-001\n", + ) + val testFile = myFixture.addFileToProject( + "src/test/kotlin/example/NamedTest.kt", + """ + package example + + import ai.unifiedprocess.tools.UseCase + + class NamedTest { + @UseCase(id = "UC-001", scenario = "UseCase") + fun greet() {} + } + """.trimIndent(), + ) + myFixture.configureFromExistingVirtualFile(testFile.virtualFile) + + val tooltips = myFixture.findAllGutters().tooltips() + assertEquals( + "expected exactly one UC-001 gutter, got $tooltips", + 1, + tooltips.count { it.contains("Go to spec for UC-001") }, + ) + } + fun testNoGutterWhenSpecMissing() { val testFile = myFixture.addFileToProject( "src/test/java/example/OrphanTest.java",