Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
27 changes: 21 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
# 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.

![AI Unified Process Diagram tool window rendering the activity diagram of a Use Case spec](docs/ai-unified-process-diagram-tool-window.png)

## 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)
Expand All @@ -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<String> = [],
)
```

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.
Expand All @@ -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:

Expand Down Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,34 +33,51 @@ class UseCaseDeclarationProvider : PsiSymbolDeclarationProvider {
element: PsiElement,
offsetInElement: Int,
): Collection<PsiSymbolDeclaration> {
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<ULiteralExpression>() ?: 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<UAnnotation>() ?: 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(
Expand Down Expand Up @@ -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
Expand Down
50 changes: 31 additions & 19 deletions src/main/kotlin/ai/unifiedprocess/tools/ij/UseCaseIdInspection.kt
Original file line number Diff line number Diff line change
@@ -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),
)
}
77 changes: 72 additions & 5 deletions src/main/kotlin/ai/unifiedprocess/tools/ij/UseCaseIndex.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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<PsiElement> {
val useCaseId = getStringAttribute(annotation, "id") ?: return emptyList()
val scenario = getStringAttribute(annotation, "scenario")
val brIds = getStringArrayAttribute(annotation, "businessRules")
fun findSpecLeavesForAnnotation(project: Project, annotation: UAnnotation): List<PsiElement> {
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<String>,
): List<PsiElement> {
val scenarioCode = scenario
?.takeIf { it.isNotBlank() && !isMainScenarioLabel(it) }
?.let { scenarioPrefix(it) }
Expand Down Expand Up @@ -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<String> {
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<UExpression> = 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 }
}

Expand All @@ -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<UExpression>()?.evaluate() as? String)?.let { return it }
// For computed expressions, fall back to text without quotes
return value.text?.trim('"')
}
Expand All @@ -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<UExpression>()?.let { uValue ->
(uValue.evaluate() as? String)?.let { return listOf(it) }
return arrayElements(uValue).mapNotNull { it.evaluate() as? String }
}
return emptyList()
}
}
Loading