diff --git a/.claude/skills/README.md b/.claude/skills/README.md new file mode 100644 index 0000000..783c6c9 --- /dev/null +++ b/.claude/skills/README.md @@ -0,0 +1,38 @@ +# Claude Code Skills for temporal-plugin + +Invoke any skill in Claude Code with `/`. + +## Plugin-development skills (this repo's stack) + +| Skill | Purpose | +|---|---| +| `/intellij-plugin-dev` | General IntelliJ Platform plugin-dev rules (sourced from the official JetBrains SDK docs). Entry point with topic files: `structure.md`, `extensions.md`, `services.md`, `threading.md`, `psi.md`, `indexes.md`, `inspections.md`, `actions-ui.md`, `gradle.md`, `kotlin.md`. | +| `/kotlin-dev` | Project-specific Kotlin conventions (common vs. languages packages, EP pattern, PHP mixins, naming, performance). | +| `/kotlin-test` | Writing JUnit 4 + IntelliJ Platform Test Framework tests for inspections, indexes, EP impls, and pure logic. | + +**How they layer:** `/intellij-plugin-dev` is the foundation (general platform +knowledge). `/kotlin-dev` adds this repo's conventions on top. `/kotlin-test` +covers tests for everything either produces. + +## Temporal SDK scaffolding skills + +These generate **official Temporal PHP SDK** patterns the plugin already +recognises via `TemporalClasses.kt`, the file-based indexes, and the PHP +inspections. + +| Skill | Purpose | +|---|---| +| `/temporal-workflow` | Create a `#[WorkflowInterface]` + implementation pair | +| `/temporal-activity` | Create an `#[ActivityInterface]` + implementation pair | +| `/temporal-signal` | Add a `#[SignalMethod]` handler to a workflow | +| `/temporal-query` | Add a `#[QueryMethod]` handler to a workflow | +| `/temporal-update` | Add an `#[UpdateMethod]` (+ optional validator) to a workflow | +| `/temporal-child-workflow` | Invoke a child workflow with `ChildWorkflowOptions` | +| `/temporal-saga` | Generate a Saga / compensation scaffold | +| `/temporal-schedule` | Create a Temporal Schedule (cron/interval) via `ScheduleClient` | +| `/temporal-worker` | Generate `worker.php` + `.rr.yaml` worker bootstrap | +| `/temporal-starter` | Generate a client script that starts a workflow | +| `/temporal-test` | Scaffold PHPUnit tests using `WorkflowEnvironment` / `ActivityMocker` | + +Each skill lives under `.claude/skills//SKILL.md` and contains the full +prompt, the code template, and the conventions Claude must follow. diff --git a/.claude/skills/intellij-plugin-dev/SKILL.md b/.claude/skills/intellij-plugin-dev/SKILL.md new file mode 100644 index 0000000..773791c --- /dev/null +++ b/.claude/skills/intellij-plugin-dev/SKILL.md @@ -0,0 +1,72 @@ +--- +name: intellij-plugin-dev +description: Develop IntelliJ Platform plugins (IDEA / PhpStorm / etc.) following the official JetBrains SDK guidelines. Use when the user asks to add an extension point, service, action, listener, inspection, index, tool window, notification, run configuration, or any other platform-level plugin feature. This is the general platform skill — for this project's specific conventions also consult /kotlin-dev. +--- + +# IntelliJ Platform Plugin Developer + +Sourced from the official JetBrains SDK docs +(). Use this skill as the +foundation; layer `/kotlin-dev` on top for this repo's conventions and +`/kotlin-test` for tests. + +## Decision tree — what kind of feature are you adding? + +| I want to... | Open | +|---|---| +| Set up / modify `plugin.xml`, module structure, resources | `structure.md` | +| Add or consume an Extension Point | `extensions.md` | +| Expose shared state / long-lived logic | `services.md` | +| Run on EDT / background; read/write PSI safely | `threading.md` | +| Walk / analyze source code | `psi.md` | +| Look up symbols fast across the project | `indexes.md` | +| Surface code warnings + quick fixes | `inspections.md` | +| Add menu items, tool windows, notifications, listeners | `actions-ui.md` | +| Change Gradle / build / runIde / verifyPlugin | `gradle.md` | +| Kotlin-specific pitfalls and idioms | `kotlin.md` | + +Each file is short and self-contained — load only the one(s) you need. + +## Ten non-negotiable platform rules + +1. **Never block the EDT.** Long work → `Task.Backgroundable`, coroutines, or + `NonBlockingReadAction`. +2. **Mutate PSI / VFS inside a `WriteAction` on the EDT only.** Reads need a + `ReadAction` from background threads. +3. **Get services on-demand; never cache them in fields** — + `service()` / `project.service()`. +4. **Register listeners declaratively** in `plugin.xml` + (``, ``) — they're created lazily. +5. **Iterate EPs lazily** — `EP.lazyDumbAwareExtensions(project)`, not + `extensionList`, unless you truly need all extensions eagerly. +6. **Mark long-computation extensions as `DumbAware`** when they don't need + indexes; otherwise gate them behind `DumbService.isDumb(project)`. +7. **Cache PSI-derived values** with `CachedValuesManager` + a + `PsiModificationTracker`. Don't hand-roll memoization. +8. **Every user-visible string** comes from a `` — no inline + English. +9. **Bump `getVersion()`** on any index whose key/value layout changes — + otherwise stale data persists across upgrades. +10. **Inspections ship with an HTML description** at + `inspectionDescriptions/.html`, or they won't pass verification. + +## Typical workflow for a new feature + +1. Decide scope — application / project / module / per-PSI-element. +2. Pick the right mechanism (service vs. EP vs. listener vs. action). +3. Declare it in `plugin.xml` (or the right `config-file` for an optional + dependency). +4. Implement in Kotlin. Keep classes `final` unless you *need* extensibility. +5. Wire i18n via `TemporalBundle.message("key")`. +6. Run `./gradlew build runIde verifyPlugin` — all three must pass. +7. Add tests per `/kotlin-test`. + +## Reference links (official) + +- Welcome: +- Plugin structure: +- Threading: +- PSI: +- Indexes: +- Kotlin for plugins: +- IntelliJ Platform Gradle Plugin (v2): diff --git a/.claude/skills/intellij-plugin-dev/actions-ui.md b/.claude/skills/intellij-plugin-dev/actions-ui.md new file mode 100644 index 0000000..771a69a --- /dev/null +++ b/.claude/skills/intellij-plugin-dev/actions-ui.md @@ -0,0 +1,151 @@ +# Actions, listeners, notifications, tool windows + +## Actions + +Official docs: + +```kotlin +class RefreshPageAction : AnAction() { + override fun getActionUpdateThread() = ActionUpdateThread.BGT + + override fun update(e: AnActionEvent) { + e.presentation.isEnabled = e.project != null + } + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + project.service().reload() + } +} +``` + +Registration: + +```xml + + + + + + +``` + +Rules: +- **Always override `getActionUpdateThread()`** — pick `BGT` when `update()` + reads project/PSI state; `EDT` only for pure UI checks. Using BGT avoids + freezes. +- `update()` must be fast. Offload work to `actionPerformed`. +- Define a `groupId` via `` + if you need a submenu; add actions to it with ``. + +## Listeners & MessageBus + +Official docs: + +Declarative (preferred — lazy, no startup cost): + +```xml + + + + + + + +``` + +Programmatic: + +```kotlin +project.messageBus.connect(parentDisposable) + .subscribe(BulkFileListener.TOPIC, object : BulkFileListener { + override fun after(events: MutableList) { /* ... */ } + }) +``` + +Custom topics: + +```kotlin +interface ServerListener { + fun onServerStarted(event: ServerStarted) + companion object { + @Topic.ProjectLevel + val TOPIC: Topic = Topic.create("Temporal server", ServerListener::class.java) + } +} +``` + +Rules: +- Listener implementations must be **stateless**; persist state in services. +- Always `connect(parentDisposable)` programmatically — orphan connections + leak. + +## Notifications + +```xml + + + +``` + +`displayType`: `BALLOON` (transient popup), `STICKY_BALLOON` (stays until +dismissed), `TOOL_WINDOW` (shown inside the target tool window, needs +`toolWindowId=`), `NONE` (event log only). + +Emit: + +```kotlin +NotificationGroupManager.getInstance() + .getNotificationGroup("Temporal") + .createNotification( + TemporalBundle.message("notification.server.started.title"), + TemporalBundle.message("notification.server.started.content", port), + NotificationType.INFORMATION, + ) + .notify(project) +``` + +## Tool windows + +Official docs: + +```xml + +``` + +```kotlin +class TemporalWindowFactory : ToolWindowFactory, DumbAware { + override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { + val panel = TemporalWebUIPanel(project) + val content = toolWindow.contentManager.factory.createContent(panel, "Web UI", false) + toolWindow.contentManager.addContent(content) + } + + override fun isApplicable(project: Project) = true // gate visibility +} +``` + +Retrieve at runtime: + +```kotlin +ToolWindowManager.getInstance(project).getToolWindow("Temporal")?.show() +``` + +Rules: +- Implement `DumbAware` unless the tool window genuinely needs indexes. +- `createToolWindowContent` runs on EDT — keep it cheap; defer heavy UI wiring + to a background task or to a button click inside the panel. diff --git a/.claude/skills/intellij-plugin-dev/extensions.md b/.claude/skills/intellij-plugin-dev/extensions.md new file mode 100644 index 0000000..941017f --- /dev/null +++ b/.claude/skills/intellij-plugin-dev/extensions.md @@ -0,0 +1,84 @@ +# Extension points & extensions + +Official docs: + +## Declaring your own EP + +```xml + + + + + + + + +``` + +Guidelines: + +- `name` must be unique **within your plugin**; the full ID becomes + `.`. +- `dynamic="true"` allows the EP to be loaded/unloaded at runtime (required + for v2 plugins that support hot reload). +- Choose `interface` for code-driven extensions, `beanClass` for XML-configured + extensions with attributes. + +## Consuming an EP in code + +```kotlin +interface Activity { + fun getActivities(project: Project): List + + companion object { + val EP = ExtensionPointName.create("com.example.my.activity") + + fun all(project: Project): List = + EP.lazyDumbAwareExtensions(project) + .flatMap { it.getActivities(project) } + .toList() + } +} +``` + +- Prefer `lazyDumbAwareExtensions(project)` — returns a lazy `Sequence` that + skips non-`DumbAware` extensions while indexing is in progress. +- `extensionList` / `extensions` eagerly instantiate **all** extensions — use + only if you actually need every one, and only from background threads. +- Extensions throwing `ExtensionNotApplicableException` in their constructor + are silently skipped. Use this to opt out based on runtime state. + +## Contributing to someone's EP + +```xml + + + + + + + + + +``` + +`defaultExtensionNs` is the plugin ID that **owns** the EP, not the caller. + +## Rules for extension implementations + +- **Stateless.** Per-instance mutable state breaks with dynamic reloading. + Store runtime state in a service instead. +- **Cheap constructors.** No I/O, no heavy initialization. Defer work to the + first real call. +- **No `object` singletons** in Kotlin for extension classes — the platform + instantiates them itself. +- **Thread-safety.** Extensions are called from many threads; assume concurrent + invocation. + +## Optional-dependency pattern + +1. `other.plugin`. +2. Put all `` that rely on `other.plugin`'s APIs inside `x.xml`. +3. If `other.plugin` is absent, `x.xml` is simply not loaded — the base plugin + keeps working with reduced features. diff --git a/.claude/skills/intellij-plugin-dev/gradle.md b/.claude/skills/intellij-plugin-dev/gradle.md new file mode 100644 index 0000000..ce279c5 --- /dev/null +++ b/.claude/skills/intellij-plugin-dev/gradle.md @@ -0,0 +1,110 @@ +# IntelliJ Platform Gradle Plugin (v2) + +Official docs: + +## Minimum setup + +```kotlin +plugins { + java + kotlin("jvm") version "2.3.0" + id("org.jetbrains.intellij.platform") version "2.11.0" + id("org.jetbrains.changelog") version "2.5.0" +} + +kotlin { + jvmToolchain(21) +} + +repositories { + mavenCentral() + intellijPlatform { defaultRepositories() } +} +``` + +## Dependencies + +```kotlin +dependencies { + testImplementation("junit:junit:4.13.2") + + intellijPlatform { + intellijIdea(providers.gradleProperty("platformVersion")) + bundledPlugins(providers.gradleProperty("platformBundledPlugins").map { it.split(',') }) + plugins(providers.gradleProperty("platformPlugins").map { it.split(',') }) + testFramework(TestFrameworkType.Platform) + } +} +``` + +Backing `gradle.properties`: + +```properties +platformVersion = 2025.1.1 +platformBundledPlugins = +platformPlugins = com.jetbrains.php:251.23774.16 +pluginSinceBuild = 251 +kotlin.stdlib.default.dependency = false +``` + +## `intellijPlatform { ... }` block + +```kotlin +intellijPlatform { + pluginConfiguration { + name = providers.gradleProperty("pluginName") + version = providers.gradleProperty("pluginVersion") + ideaVersion { + sinceBuild = providers.gradleProperty("pluginSinceBuild") + } + } + + signing { + certificateChain = providers.environmentVariable("CERTIFICATE_CHAIN") + privateKey = providers.environmentVariable("PRIVATE_KEY") + password = providers.environmentVariable("PRIVATE_KEY_PASSWORD") + } + + publishing { + token = providers.environmentVariable("PUBLISH_TOKEN") + channels = providers.gradleProperty("pluginVersion").map { listOf(/* stable / eap / ... */) } + } + + pluginVerification { + ides { recommended() } // verify against the IDE versions JetBrains recommends + } +} +``` + +## Common tasks + +| Task | Purpose | +|---|---| +| `build` | Compile + package | +| `runIde` | Launch a sandbox IDE with the plugin loaded | +| `runIdeForUiTests` | Launch with `robot-server` for UI tests | +| `test` | Run JUnit tests (uses the Platform test framework) | +| `verifyPlugin` | Run IntelliJ Plugin Verifier against `recommended()` IDEs | +| `buildPlugin` | Produce `build/distributions/-.zip` | +| `publishPlugin` | Upload to JetBrains Marketplace | +| `patchChangelog` | Finalize the unreleased section in `CHANGELOG.md` | +| `koverXmlReport` | Generate coverage XML at `build/reports/kover/report.xml` | + +Run the verifier before every release: + +```bash +./gradlew verifyPlugin +``` + +It catches `NoSuchMethodError`-style API breaks across supported IDE versions. + +## Rules + +- Pin `platformVersion` and `pluginSinceBuild` — no wildcards. +- Keep `kotlin.stdlib.default.dependency = false` (the IDE bundles the stdlib; + shipping another copy breaks classloading). +- Use `org.jetbrains.intellij.platform.module` instead of the main plugin in + Gradle *submodules*, otherwise signing/publishing tasks pollute every + module. +- Enable `org.gradle.configuration-cache = true` + `org.gradle.caching = true` + for fast incremental builds (this repo already does). diff --git a/.claude/skills/intellij-plugin-dev/indexes.md b/.claude/skills/intellij-plugin-dev/indexes.md new file mode 100644 index 0000000..6694874 --- /dev/null +++ b/.claude/skills/intellij-plugin-dev/indexes.md @@ -0,0 +1,88 @@ +# File-based & stub indexes + +Official docs: + +## When to index + +You index when you need to answer "which files/elements match X?" across the +whole project *quickly*. Linear PSI scans are fine for a single file; for +project-wide lookups use an index. + +## File-based index — skeleton + +```kotlin +object WorkflowMethodKey { + val NAME = ID.create("com.example.WorkflowMethodIndex") +} + +class WorkflowMethodIndex : ScalarIndexExtension() { + override fun getName(): ID = WorkflowMethodKey.NAME + override fun getKeyDescriptor(): KeyDescriptor = EnumeratorStringDescriptor.INSTANCE + override fun getVersion(): Int = 1 // bump on schema changes + override fun dependsOnFileContent(): Boolean = true + override fun getInputFilter(): FileBasedIndex.InputFilter = + DefaultFileTypeSpecificInputFilter(PhpFileType.INSTANCE) + + override fun getIndexer(): DataIndexer = + DataIndexer { content -> + val keys = mutableMapOf() + val file = content.psiFile as? PhpFile ?: return@DataIndexer keys + file.accept(object : PhpElementVisitor() { + override fun visitPhpMethod(m: Method) { + if (m.isWorkflow()) keys["${m.containingClass?.fqn}::${m.name}"] = null + super.visitPhpMethod(m) + } + }) + keys + } +} +``` + +Register: + +```xml + +``` + +This repo already has `common/index/AbstractIndex.kt` — subclass it rather +than re-implementing the boilerplate. + +## Querying + +```kotlin +val idx = FileBasedIndex.getInstance() +val keys = idx.getAllKeys(WorkflowMethodKey.NAME, project) +val files = idx.getContainingFiles( + WorkflowMethodKey.NAME, "App\\OrderWorkflow::run", + GlobalSearchScope.projectScope(project), +) +idx.processValues(WorkflowMethodKey.NAME, key, /*scope*/null, { file, _ -> /* ... */ true }, scope) +``` + +`getAllKeys` returns *all* keys ever indexed — filter out stale ones by +checking `getContainingFiles(...).isNotEmpty()` inside a read action. + +## Variants + +- `ScalarIndexExtension` — key only, no value. Simplest. +- `FileBasedIndexExtension` — key + value (provide a + `DataExternalizer`). +- **Stub indexes** (`StringStubIndexExtension` et al.) — built on top of + stored stubs of a PSI tree; return PSI elements instead of files. Preferred + for custom-language plugins when you need element-level granularity. + +## Dumb mode + +Indexes are built in a background "dumb" phase. While dumb: +- `FileBasedIndex.getAllKeys` may throw `IndexNotReadyException`. +- Features that need indexes must either wait (`DumbService.runWhenSmart`) or + implement `DumbAware` with a restricted fallback. + +## Hygiene + +- **Bump `getVersion()`** whenever key/value layout changes — stale caches + otherwise linger after upgrade and cause data corruption. +- Keep indexers pure and fast; no I/O beyond `FileContent`, no PSI access + outside `content.psiFile`, no project services. +- Filter aggressively via `getInputFilter()` — indexing irrelevant files is + the #1 cause of slow project open. diff --git a/.claude/skills/intellij-plugin-dev/inspections.md b/.claude/skills/intellij-plugin-dev/inspections.md new file mode 100644 index 0000000..d6b587c --- /dev/null +++ b/.claude/skills/intellij-plugin-dev/inspections.md @@ -0,0 +1,108 @@ +# Local inspections & quick fixes + +Official docs: + +## Anatomy + +1. **Inspection class** — extends `LocalInspectionTool` (or a language-specific + subclass like `PhpInspection`, `AbstractKotlinInspection`). +2. **Visitor** — walks the PSI and calls + `holder.registerProblem(anchor, message, ...quickFixes)`. +3. **Quick fix** — implements `LocalQuickFix` and mutates the PSI. +4. **Registration** — `` in `plugin.xml` (or the language + config-file). +5. **Description** — `inspectionDescriptions/.html` (required; + verifier fails without it). + +## Skeleton (PHP flavour) + +```kotlin +class PhpActivityMethodInspection : PhpInspection() { + override fun buildVisitor(holder: ProblemsHolder, isOnTheFly: Boolean): PsiElementVisitor = + object : PhpElementVisitor() { + override fun visitPhpMethod(method: Method) { + if (!method.isActivity()) return + if (method.hasAttribute(TemporalClasses.ACTIVITY_METHOD)) return + + holder.registerProblem( + method.nameIdentifier ?: method, + TemporalBundle.message("inspection.php.activity.method.attribute.missing.problem.description"), + AddActivityMethodAttributeQuickFix(), + ) + } + } +} +``` + +## Quick-fix skeleton + +```kotlin +class AddActivityMethodAttributeQuickFix : LocalQuickFix { + override fun getFamilyName(): String = + TemporalBundle.message("inspection.php.activity.method.attribute.missing.quickfix.name") + + override fun applyFix(project: Project, descriptor: ProblemDescriptor) { + val method = descriptor.psiElement.parentOfType() ?: return + val attr = PhpPsiElementFactory.createPhpAttribute(project, "#[\\Temporal\\Activity\\ActivityMethod]") + WriteCommandAction.runWriteCommandAction(project) { + method.addBefore(attr, method.firstChild) + } + } +} +``` + +Notes: +- `getFamilyName()` is what shows up in the UI — localise it. +- Quick-fix PSI mutations must run inside `WriteCommandAction` (the inspection + framework wraps `applyFix` in a command, but still run `runWriteCommandAction` + if you need undo-grouping and modality control). +- Don't rely on `descriptor.psiElement` still being the element you + registered — resolve the enclosing node fresh (`parentOfType<...>()`). + +## Registration + +```xml + + + +``` + +Attributes: +- `shortName` must match the class name minus the `Inspection` suffix *and* + the HTML description filename: `inspectionDescriptions/PhpActivityMethodInspection.html`. +- `key`/`bundle` resolves the display name via i18n. +- `level` — `ERROR | WARNING | WEAK WARNING | INFORMATION`. +- `groupPath` / `groupName` determine placement in `Settings → Editor → Inspections`. + +## Description HTML + +```html + + +Reports Activity methods missing the #[ActivityMethod] attribute. + + +Temporal uses #[ActivityMethod] to expose methods to the worker. +Methods without it are invisible to the runtime. + + +``` + +The `` marker separates the short tooltip from the long +description shown in the settings dialog. + +## Testing + +See `/kotlin-test` for the `BasePlatformTestCase` recipe — inspection tests +call `myFixture.enableInspections(...)` in `setUp`, then +`configureByFile` + `checkHighlighting` / `findSingleIntention` + +`checkResultByFile`. diff --git a/.claude/skills/intellij-plugin-dev/kotlin.md b/.claude/skills/intellij-plugin-dev/kotlin.md new file mode 100644 index 0000000..6f106be --- /dev/null +++ b/.claude/skills/intellij-plugin-dev/kotlin.md @@ -0,0 +1,98 @@ +# Kotlin in IntelliJ Platform plugins + +Official docs: + +## Version alignment + +- IntelliJ Platform bundles its own Kotlin runtime. Your plugin's compiled + Kotlin must be **binary-compatible** with the bundled version. +- Kotlin **2.x is required** for platform `2025.1+`, recommended for 2024.3+. +- Compile with the JVM toolchain the platform uses — currently **Java 21**. + +## Don't ship the stdlib + +```properties +# gradle.properties +kotlin.stdlib.default.dependency = false +``` + +The IDE already ships `kotlin-stdlib`. Shipping a second copy causes +`NoSuchMethodError` / classloader collisions. `verifyPlugin` checks this. + +## Idiomatic APIs + +```kotlin +val app = service() // application-level service +val svc = project.service() // project-level service +val ep = ExtensionPointName.create("com.example.activity") +ep.lazyDumbAwareExtensions(project).forEach { /* ... */ } +``` + +## Anti-patterns + +- **No `object` declarations as extensions.** The platform uses DI to + instantiate extension classes; a Kotlin singleton can't be re-instantiated + on reload. Use a regular `class` + an `@Service` if you need a singleton. +- **No heavy work in `companion object { init { ... } }`.** That runs on + classload, which in turn runs on IDE startup. Defer to first method call. +- **No `lateinit var` for platform services.** Always re-fetch via + `service()` / `project.service()`. +- **No `!!` on PSI calls.** PSI operations frequently return null during + indexing — use `?: return`. + +## Interop when Java code calls you + +If Java code needs to call your Kotlin API: + +```kotlin +class Api { + companion object { + @JvmStatic + fun of(project: Project): Api = project.service() + } +} +``` + +- `@JvmStatic` on companion methods to avoid `Api.Companion.of(...)`. +- `@JvmField` on companion constants to expose them as real static fields. +- `@file:JvmName("Xyz")` on top-level function files if you want a cleaner + Java-visible class name. + +## Coroutines + +Modern platform APIs expose suspending variants: + +```kotlin +suspend fun load(project: Project): List = readAction { + Workflow.all(project) +} + +scope.launch { + val data = load(project) + withContext(Dispatchers.EDT) { updateUi(data) } +} +``` + +Do not create your own top-level `GlobalScope.launch` — bind scopes to the +lifecycle of a service or a `Disposable`. The easiest way is an +`@Service`-annotated class taking a `CoroutineScope` as a constructor +parameter (see `services.md`). + +## Incremental compile hiccups + +If builds OOM on large JARs, disable the classpath snapshot (rare, usually +only for platform = 2024.1 / Kotlin 1.8.20): + +```properties +kotlin.incremental.useClasspathSnapshot = false +``` + +Most recent Kotlin versions handle this correctly. + +## Style + +- `val` over `var`. `data class` for plain data. `sealed class`/`sealed interface` + for closed hierarchies. +- Extension functions over static util classes. +- Keep files short and named after the primary declaration. +- No wildcard imports. diff --git a/.claude/skills/intellij-plugin-dev/psi.md b/.claude/skills/intellij-plugin-dev/psi.md new file mode 100644 index 0000000..ea2bb23 --- /dev/null +++ b/.claude/skills/intellij-plugin-dev/psi.md @@ -0,0 +1,104 @@ +# PSI — Program Structure Interface + +Official docs: + +## What is PSI? + +PSI is the semantic code model the platform builds on top of the lexer/parser +AST. Every source file becomes a `PsiFile` with a tree of `PsiElement` nodes +(classes, methods, expressions, etc.). Plugins operate on PSI — never on raw +text. + +- **AST** = raw syntax tree (`ASTNode`). Rarely needed directly. +- **PSI** = AST + language-specific semantics (`PhpClass`, `PsiMethod`, + `KtFunction`, ...). Use this. + +## Getting PSI + +```kotlin +val psiFile: PsiFile? = PsiManager.getInstance(project).findFile(virtualFile) +val psiEl: PsiElement = editor.caretModel.currentCaret.let { psiFile!!.findElementAt(it.offset)!! } +``` + +Getting PSI is a **read** operation — wrap in a read action on background +threads. + +## Navigation + +```kotlin +element.parent // up one level +element.children // direct children (most) +element.firstChild / .lastChild +element.nextSibling / .prevSibling +element.containingFile // PsiFile +PsiTreeUtil.getParentOfType(element, PhpClass::class.java) +PsiTreeUtil.findChildOfType(element, Method::class.java) +PsiTreeUtil.findChildrenOfType(file, Method::class.java) +``` + +Always null-check `getParentOfType` results. + +## Visitors + +Use a visitor instead of manual recursion: + +```kotlin +file.accept(object : PsiRecursiveElementWalkingVisitor() { + override fun visitElement(element: PsiElement) { + if (element is PhpClass && element.isWorkflow()) { + process(element) + } + super.visitElement(element) + } +}) +``` + +For language-specific visits, use the language's visitor (e.g. +`PhpElementVisitor`, `JavaElementVisitor`, `KtTreeVisitorVoid`) — gives you +typed `visit` methods. + +## References + +`PsiReference` links a usage site to its declaration: + +```kotlin +val refs = ReferencesSearch.search(targetElement).findAll() +val resolved = reference.resolve() // PsiElement or null +val polyResolved = (reference as PsiPolyVariantReference).multiResolve(false) +``` + +Contribute new references via `PsiReferenceContributor` + `PsiReferenceProvider` +registered under ``. + +## Patterns + +`PlatformPatterns` / `StandardPatterns` describe where a contribution applies — +used for completion, references, injections: + +```kotlin +val pattern = PlatformPatterns + .psiElement(PhpTokenTypes.STRING_LITERAL) + .inside(PlatformPatterns.psiElement(StringLiteralExpression::class.java)) + .withLanguage(PhpLanguage.INSTANCE) +``` + +## Modifying PSI + +Build new elements via a language's `ElementFactory` (e.g. +`PhpPsiElementFactory.createPhpAttributeFromText(project, "#[ActivityMethod]")`) +and splice them in with `addBefore`, `addAfter`, `replace`. Wrap **every +modification** in a `WriteCommandAction` (see `threading.md`). + +## Validity + +PSI elements can become invalid after a reparse. When jumping between actions +or threads: + +```kotlin +if (!element.isValid) return +``` + +## Debugging + +`Tools → View PSI Structure` in the IDE shows the live PSI tree of the current +file — invaluable when writing visitors or inspections. diff --git a/.claude/skills/intellij-plugin-dev/services.md b/.claude/skills/intellij-plugin-dev/services.md new file mode 100644 index 0000000..8ff4812 --- /dev/null +++ b/.claude/skills/intellij-plugin-dev/services.md @@ -0,0 +1,95 @@ +# Services (application / project / module) + +Official docs: + +## Scopes + +| Scope | Lifetime | Declaration | +|---|---|---| +| Application | IDE process | `@Service` / `` | +| Project | Open project | `@Service(Service.Level.PROJECT)` / `` | +| Module | Module lifetime | `` (avoid — memory heavy) | + +## Light services (preferred) + +```kotlin +@Service(Service.Level.APP) +class IconCache { + private val cache = ConcurrentHashMap() + fun get(key: String): Icon = cache.getOrPut(key) { load(key) } +} + +@Service(Service.Level.PROJECT) +class TemporalExecutablesSettings(private val project: Project) { + var executables: List = emptyList() +} +``` + +Rules: +- Must be `final`. +- Constructor may take `Project` (for project services) and/or `CoroutineScope`. +- **No XML registration needed** — `@Service` is enough. +- Not overrideable by other plugins. + +## Classic services + +Use when you need an interface + swappable implementation, or when the service +must be visible as an API to other plugins: + +```xml + + + + +``` + +## Retrieval + +```kotlin +// Kotlin, idiomatic +val icons = service() +val settings = project.service() + +// Java +IconCache icons = ApplicationManager.getApplication().getService(IconCache.class); +TemporalExecutablesSettings s = project.getService(TemporalExecutablesSettings.class); +``` + +Rules: +- **Always on-demand.** Never cache a service reference in a field — it breaks + across project close / plugin reload. +- **No read action needed** to obtain a service. It's safe from any thread. +- **Don't inject services via constructor** of another service / extension — + that pattern is deprecated; call `service()` inside methods instead. + +## State persistence + +Implement `PersistentStateComponent` and annotate with `@State`: + +```kotlin +@Service(Service.Level.PROJECT) +@State(name = "TemporalSettings", + storages = [Storage("temporal.xml")]) +class TemporalSettings : PersistentStateComponent { + data class State(var address: String = "127.0.0.1:7233") + private var state = State() + override fun getState(): State = state + override fun loadState(loaded: State) { state = loaded } +} +``` + +## Disposal & coroutines + +- Services implementing `Disposable` receive a `dispose()` callback — register + disposables with `Disposer.register(this, child)`. +- A `CoroutineScope` parameter is tied to the service lifetime: + +```kotlin +@Service(Service.Level.PROJECT) +class BackgroundWorker(private val scope: CoroutineScope) { + fun schedule() = scope.launch { /* cancelled on project close */ } +} +``` diff --git a/.claude/skills/intellij-plugin-dev/structure.md b/.claude/skills/intellij-plugin-dev/structure.md new file mode 100644 index 0000000..e8958b4 --- /dev/null +++ b/.claude/skills/intellij-plugin-dev/structure.md @@ -0,0 +1,97 @@ +# Plugin structure & `plugin.xml` + +Official docs: + +## Project layout + +``` +src/main/ +├── kotlin/... # production sources +└── resources/ + ├── META-INF/ + │ ├── plugin.xml # main descriptor + │ └── .xml # optional config-file includes + ├── messages/ + │ └── .properties # i18n + ├── inspectionDescriptions/ + │ └── .html # one per + └── icons/ # SVG icons, addressable as /icons/foo.svg +``` + +## Minimal `plugin.xml` + +```xml + + com.example.my + My Plugin + Me + + com.intellij.modules.platform + com.jetbrains.php + + messages.MyBundle + + + + + + + + + + + + + + + + + + + + + +``` + +## Key attributes + +- `` — globally unique (reverse-DNS style). +- `` + - `com.intellij.modules.platform` — required for every plugin. + - `optional="true" config-file="..."` — loads extra XML **only if** the + dependency is present. Use this for language-specific code (e.g. PHP). +- `` — default bundle for `@key=...` attributes in this + descriptor (inspections, settings, notifications). +- `` vs ``: + - EPs you **define** go inside ``. + - EPs you **contribute to** (yours or someone else's) go inside + ``. + +## Config-file splitting + +Language/feature-specific code should live in a separate XML: + +```xml + +com.jetbrains.php +``` + +```xml + + + + + + +``` + +If the optional plugin isn't installed, the file is simply not loaded — your +plugin still works without it. + +## `pluginSinceBuild` & platform version + +Declare the minimum IDE build in `intellijPlatform.pluginConfiguration.ideaVersion.sinceBuild` +(Gradle) — corresponds to `` in generated +`plugin.xml`. Pin the concrete platform version in `gradle.properties` +(`platformVersion=2025.1.1`) so builds are reproducible. diff --git a/.claude/skills/intellij-plugin-dev/threading.md b/.claude/skills/intellij-plugin-dev/threading.md new file mode 100644 index 0000000..d39ef00 --- /dev/null +++ b/.claude/skills/intellij-plugin-dev/threading.md @@ -0,0 +1,93 @@ +# Threading model + +Official docs: + +## Two worlds + +- **EDT** (Event Dispatch Thread) — exactly one; owns UI updates and *all* + write access to PSI/VFS/project model. Must stay responsive (< 100 ms). +- **BGT** (background threads) — many; perform long work, indexing reads, + process launches, network I/O. + +## Read / write locks + +| Action | Where | Purpose | +|---|---|---| +| `ReadAction.run { ... }` / `runReadAction { ... }` | any thread | Read PSI / VFS / indexes | +| `ReadAction.nonBlocking { ... }.submit(...)` | BGT | Read that yields to pending writes | +| `WriteAction.run { ... }` / `runWriteAction { ... }` | **EDT only** | Modify PSI / VFS / project model | +| `invokeLater { ... }` | anywhere → EDT | Schedule work on EDT | +| `invokeAndWait { ... }` | BGT → EDT (blocking) | Rare; use sparingly | + +## Patterns + +### Short read on background, then update UI + +```kotlin +ReadAction + .nonBlocking> { Workflow.all(project) } + .inSmartMode(project) + .finishOnUiThread(ModalityState.defaultModalityState()) { list -> + updateUi(list) + } + .submit(AppExecutorUtil.getAppExecutorService()) +``` + +### Long work with progress + +```kotlin +ProgressManager.getInstance().run(object : Task.Backgroundable(project, "Indexing workflows") { + override fun run(indicator: ProgressIndicator) { + indicator.isIndeterminate = false + // do the work; periodically indicator.checkCanceled() + } +}) +``` + +### Writing PSI + +```kotlin +WriteCommandAction.runWriteCommandAction(project, "Add ActivityMethod attribute", null, { + // edit PSI here; wrapped in a write action + undo-friendly command + method.addBefore(factory.createAttribute("..."), method.firstChild) +}, psiFile) +``` + +## Modality state + +Dialogs create modal contexts. When scheduling EDT work from a background +thread, pass a `ModalityState`: + +```kotlin +ApplicationManager.getApplication().invokeLater( + { updateUi() }, + ModalityState.defaultModalityState(), +) +``` + +Without it, your runnable may fire while a modal dialog is open — risking +corrupted state. + +## Kotlin coroutines (2024.1+) + +```kotlin +@Service(Service.Level.PROJECT) +class Foo(private val scope: CoroutineScope) { + fun refresh() = scope.launch { + val data = readAction { Workflow.all(project) } // suspends; cooperative with writes + withContext(Dispatchers.EDT) { updateUi(data) } + } +} +``` + +- `readAction { ... }` and `writeAction { ... }` are suspending analogues of + the blocking actions, and respect cancellation. +- Prefer `Dispatchers.EDT` over `invokeLater` inside coroutines. + +## Hard rules + +- Never call blocking I/O, `Thread.sleep`, or long computations on EDT. +- Never hold the read lock across EDT ↔ BGT transitions — release and reacquire. +- Always check object validity (`psiElement.isValid`) between read actions. +- Writes **must** be inside `WriteCommandAction` for anything user-visible + (undo support). diff --git a/.claude/skills/kotlin-dev/SKILL.md b/.claude/skills/kotlin-dev/SKILL.md new file mode 100644 index 0000000..9ee0221 --- /dev/null +++ b/.claude/skills/kotlin-dev/SKILL.md @@ -0,0 +1,152 @@ +--- +name: kotlin-dev +description: Write new Kotlin code for this IntelliJ Platform plugin following the project's common/languages architecture, Extension Point pattern, and performance/naming rules. Use whenever the user asks to "add a Kotlin class", "implement an inspection", "add an action", "create a service", "write a completion provider", or any new plugin-side feature. +--- + +# Kotlin Developer — temporal-plugin stack + +This is an IntelliJ Platform plugin (`com.github.xepozz.temporal`, since-build +`251`, platform `2025.1.1`). All production code is Kotlin 2.3, JVM toolchain +21. Follow the architecture and conventions below *exactly* — the project is +small and consistent, and divergence is noise. + +## Before you write code + +1. **Locate the right package** (`common/` vs `languages//`): + - Language-agnostic logic → `common/...` + - Logic that touches PSI of a specific language → `languages//...` + - Feature shared across languages → define an EP in `common/extensionPoints/` + and add language adapters under `languages//...`. +2. **Check for an existing Extension Point** in `common/extensionPoints/` first + (`Workflow`, `Activity`). Reuse before creating. +3. **Check `languages/php/mixin.kt`** — helpers like `PhpClass.isActivity()`, + `Method.isWorkflow()`, `PhpAttributesOwner.hasAttribute(fqn)` already exist. +4. **Check `languages/php/TemporalClasses.kt`** for the Temporal FQCN you need — + never hard-code `"\\Temporal\\..."` strings in new code. + +## Extension Point recipe + +When a feature must work across languages, add five pieces in this order: + +1. **EP interface** in `common/extensionPoints/.kt`: + + ```kotlin + interface Workflow { + fun getWorkflows(project: Project, module: Module? = null): List + + companion object { + val EP = ExtensionPointName.create("com.github.xepozz.temporal.workflow") + + fun getWorkflows(project: Project, module: Module? = null): List = + EP.lazyDumbAwareExtensions(project).flatMap { it.getWorkflows(project, module) }.toList() + } + } + ``` + +2. **Register the EP** in `src/main/resources/META-INF/plugin.xml`: + + ```xml + + + + ``` + +3. **Shared consumer** in `common//Provider.kt` — iterate + via `Workflow.getWorkflows(project)`, never `EP.extensionList`. +4. **Language adapter** in `languages/php//Php.kt` — + prefixed with the language name, implements the interface. +5. **Register the adapter** in the language-specific XML + (`src/main/resources/META-INF/language-php.xml`) under + `defaultExtensionNs="com.github.xepozz.temporal"`. + +## Hard rules + +- **Kotlin-first**: never add new Java files. All new code is Kotlin. +- **Namespaces**: every EP name/ID and every plugin extension ID starts with + `com.github.xepozz.temporal`. +- **Naming**: + - EP interfaces are named after the entity — `Workflow`, `Activity`, never + `WorkflowEP` / `ActivityEP`. + - Language adapters are prefixed: `PhpWorkflow`, `PhpActivity`. Mirror the + sub-package path (`languages/php/navigation` ↔ future `languages/go/navigation`). +- **Mirror package layout** when adding a new language adapter — copy PHP's + sub-packages (`endpoints/`, `index/`, `inspections/`). +- **i18n**: every user-facing string goes through `TemporalBundle.message("...")`, + with the key defined in + `src/main/resources/messages/TemporalBundle.properties`. No inline English. +- **Inspection descriptions**: when adding a ``, also add + `src/main/resources/inspectionDescriptions/.html`. +- **Minimal change**: implement the smallest diff that satisfies the goal. + No speculative abstractions, no unused parameters, no "just in case" try/catch. + +## Performance rules (these matter inside the IDE) + +- Iterate EPs with `EP.lazyDumbAwareExtensions(project)` — **never** + `extensionList`. The former is dumb-mode aware and lazily instantiated. +- Guard PSI-heavy computations behind `DumbService.isDumb(project)` checks, or + implement `DumbAware` on the extension. +- Cache expensive derivations with `CachedValuesManager.getCachedValue(element)` + using `PsiModificationTracker.MODIFICATION_COUNT` (or a more specific + tracker). Don't roll your own memoization. +- File-based indexes go through `com.github.xepozz.temporal.common.index.AbstractIndex` + — subclass it, don't reimplement `FileBasedIndexExtension` from scratch. +- Long-running work (HTTP, process launches) must not block the EDT. Use + `BackgroundTaskQueue`, `ProgressManager.run(Task.Backgroundable)`, or + coroutines with `AppExecutorUtil`. + +## PHP-specific guidance + +- Prefer the mixin helpers over manual PSI walks: + + ```kotlin + // Good + if (phpClass.isWorkflow()) { ... } + if (method.isActivity()) { ... } + if (phpClass.hasInterface(TemporalClasses.WORKFLOW)) { ... } + + // Bad — don't reinvent these + phpClass.attributes.any { it.fqn == "\\Temporal\\Workflow\\WorkflowInterface" } + ``` + +- `Method.isActivity()` / `Method.isWorkflow()` are deliberately **tolerant** + (public, non-static, non-abstract, in the right class — attribute optional). + Use them when you want "conceptually an activity method"; use an explicit + `hasAttribute(TemporalClasses.ACTIVITY_METHOD)` when you truly need the + annotation to be present. +- Inspections extend `PhpInspection` and build a `PhpElementVisitor`. Return + early on non-matches; register problems via `holder.registerProblem(anchor, bundleMessage, ...QuickFixes)`. + +## Style + +- Prefer `val` over `var`. Prefer data classes over hand-rolled equals/hashCode. +- Prefer `when` over chained `if`/`else if` for disjoint cases (see + `mixin.kt:isActivity()`). +- No wildcard imports. No `!!` unless genuinely unreachable; use `?: return`, + `?.let`, or `requireNotNull(...)` instead. +- No unused `@Suppress` annotations, no "TODO:" without an owner/context. + +## Register & verify + +Whenever you add a class extended by the platform: + +1. Update `plugin.xml` (common) or `language-php.xml` (PHP) — **both locations + are required** for adapters. +2. Run `./gradlew build` to confirm XML + Kotlin compile. +3. Run `./gradlew verifyPlugin` for API-compatibility checks. +4. Run `./gradlew runIde` to smoke-test the feature in the sandbox IDE. + +## Reference files (skim these before writing similar code) + +| You're writing... | Open this first | +|---|---| +| An inspection | `languages/php/inspections/PhpActivityMethodInspection.kt` | +| A quick fix | `languages/php/inspections/AddActivityMethodAttributeQuickFix.kt` | +| A file-based index | `languages/php/index/PhpActivityClassIndex.kt` + `common/index/AbstractIndex.kt` | +| An EP interface | `common/extensionPoints/Activity.kt` | +| A PHP adapter | `languages/php/endpoints/PhpActivity.kt` | +| An endpoints provider | `common/endpoints/TemporalActivityEndpointsProvider.kt` | +| A tool window / action | `common/uiViewer/TemporalWindowFactory.kt`, `common/uiViewer/actions/*` | +| A settings page | `common/configuration/TemporalExecutablesConfigurable.kt` | +| A run configuration | `common/run/TemporalConfigurationType.kt` + siblings | +| A PSI helper | `languages/php/mixin.kt` | diff --git a/.claude/skills/kotlin-test/SKILL.md b/.claude/skills/kotlin-test/SKILL.md new file mode 100644 index 0000000..f95d8e1 --- /dev/null +++ b/.claude/skills/kotlin-test/SKILL.md @@ -0,0 +1,221 @@ +--- +name: kotlin-test +description: Write JUnit 4 + IntelliJ Platform Test Framework tests in Kotlin for this plugin (inspections, indexes, endpoint providers, EP implementations, PSI helpers, run configs). Use when the user asks to "add a test", "write a test", "cover X with tests", or "bootstrap the test suite". +--- + +# Kotlin Test Writer — temporal-plugin stack + +Tests are **JUnit 4** (the project's declared test framework) run through the +**IntelliJ Platform Test Framework** (`testFramework(TestFrameworkType.Platform)` +in `build.gradle.kts`). Coverage is tracked by **Kover** (XML reported on +`check`). The project currently has no `src/test/` directory — if you're the +first person adding a test, lay the conventions below down deliberately. + +## Layout (bootstrap on first test) + +``` +src/test/ +├── kotlin/com/github/xepozz/temporal/... # mirror of src/main/kotlin +└── resources/ + ├── php/fixtures/... # .php fixture files + └── projectFixtures/... # multi-file project fixtures +``` + +- Mirror the production package exactly so a test sits next to the class it + covers (e.g. `languages/php/inspections/PhpActivityMethodInspectionTest.kt`). +- Test data lives under `src/test/resources/` addressed from tests via + `getTestDataPath()`. + +## Base classes — pick the right one + +| Scenario | Base class | +|---|---| +| Pure logic, no PSI / no project | `junit.framework.TestCase` (or plain JUnit 4 `class X { @Test fun ... }`) | +| Needs a light in-memory project + PSI | `com.intellij.testFramework.fixtures.BasePlatformTestCase` | +| Needs Java PSI too | `LightJavaCodeInsightFixtureTestCase` | +| PHP PSI / inspections / completion | `com.jetbrains.php.testFramework.PhpCodeInsightFixtureTestCase` | +| Multi-module / real filesystem project | `HeavyPlatformTestCase` (avoid unless you must) | + +`BasePlatformTestCase` and the PHP variant expose `myFixture` +(`CodeInsightTestFixture`) — the main driver. + +## Test data path convention + +```kotlin +override fun getTestDataPath(): String = + com.intellij.openapi.application.PathManager.getCommunityHomePath() // ⚠ don't use +``` + +Use the project-root-relative path instead: + +```kotlin +override fun getTestDataPath(): String = + java.io.File("src/test/resources").absolutePath +``` + +Fixture files live under that root, e.g. +`src/test/resources/php/inspections/activityMethodMissingAttribute.php`. + +## Style rules + +- **JUnit 4** — test methods named `testCamelCase`, no `fun should ...` + backtick names (the platform framework expects `test...` prefixes via + `UsefulTestCase`). Annotate with `@org.junit.Test` only when extending + a plain JUnit class; `BasePlatformTestCase` discovers `test...` methods + automatically, no annotation needed. +- **Arrange / Act / Assert** with blank lines between phases. +- **One behaviour per test.** Split data-driven cases into distinct `test...` + methods (or drive them with a helper method, not a loop inside a single test). +- **No Mockito / MockK** unless unavoidable — prefer real platform fixtures. + The IDE test framework gives you a real `Project`, `PsiManager`, indexes etc. +- **No `runBlocking`** in test bodies — use the fixture's synchronous API. If + async is truly required, use `PlatformTestUtil.dispatchAllEventsInIdeEventQueue()`. +- **Dispose explicitly** of `Disposable` resources you create via + `Disposer.register(testRootDisposable, ...)`. +- Use **`TemporalBundle.message(...)`** as the source of truth for expected + strings, never hard-code translated text in assertions. + +## Template — inspection test (with quick-fix) + +File: `src/test/kotlin/com/github/xepozz/temporal/languages/php/inspections/PhpActivityMethodInspectionTest.kt` + +```kotlin +package com.github.xepozz.temporal.languages.php.inspections + +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.io.File + +class PhpActivityMethodInspectionTest : BasePlatformTestCase() { + + override fun getTestDataPath(): String = + File("src/test/resources/php/inspections").absolutePath + + override fun setUp() { + super.setUp() + myFixture.enableInspections(PhpActivityMethodInspection::class.java) + } + + fun testWarnsWhenActivityMethodMissesAttribute() { + myFixture.configureByFile("activityMethodMissingAttribute.php") + + myFixture.checkHighlighting(/*checkWarnings = */ true, false, false) + } + + fun testQuickFixAddsActivityMethodAttribute() { + myFixture.configureByFile("activityMethodMissingAttribute.php") + val fix = myFixture.findSingleIntention( + com.github.xepozz.temporal.TemporalBundle.message( + "inspection.php.activity.method.attribute.missing.quickfix.name" + ) + ) + + myFixture.launchAction(fix) + + myFixture.checkResultByFile("activityMethodMissingAttribute.after.php") + } +} +``` + +Fixture `activityMethodMissingAttribute.php` uses `` markers: + +```php +reserve(string $orderId): void; +} +``` + +## Template — file-based index test + +```kotlin +package com.github.xepozz.temporal.languages.php.index + +import com.intellij.psi.search.GlobalSearchScope +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.util.indexing.FileBasedIndex +import java.io.File + +class PhpActivityClassIndexTest : BasePlatformTestCase() { + + override fun getTestDataPath(): String = + File("src/test/resources/php/index").absolutePath + + fun testIndexesClassesMarkedWithActivityInterface() { + myFixture.copyDirectoryToProject("activityFixture", "") + + val keys = FileBasedIndex.getInstance() + .getAllKeys(PhpActivityClassIndex.KEY, project) + .filter { fqn -> + FileBasedIndex.getInstance() + .getContainingFiles(PhpActivityClassIndex.KEY, fqn, GlobalSearchScope.allScope(project)) + .isNotEmpty() + } + + assertContainsElements(keys, "\\App\\Activity\\OrderActivity") + } +} +``` + +## Template — pure logic (no platform) + +For small, PSI-free utilities in `common/` (e.g. option normalization, URL +builders), stay lightweight — no `BasePlatformTestCase`: + +```kotlin +package com.github.xepozz.temporal.common.run + +import org.junit.Assert.assertEquals +import org.junit.Test + +class TemporalRunConfigurationOptionsTest { + + @Test + fun `defaults produce the expected CLI args`() { + val opts = TemporalRunConfigurationOptions().apply { + port = 7233 + uiPort = 8233 + } + + assertEquals(listOf("--port", "7233", "--ui-port", "8233"), opts.toCliArgs()) + } +} +``` + +(Plain JUnit 4 classes *may* use backtick-quoted test names — the platform +discovery rule only applies when extending `UsefulTestCase`.) + +## Running tests + +```bash +./gradlew test # run the whole suite +./gradlew test --tests '*PhpActivityMethodInspectionTest*' +./gradlew koverXmlReport # coverage report (build/reports/kover/report.xml) +``` + +The `.run/Run Tests.run.xml` configuration is pre-wired if you prefer the +IDE runner. + +## What not to test + +- Don't test IntelliJ Platform APIs — trust `FileBasedIndex`, `CachedValuesManager`, + `PhpInspection`, etc. Test **your** glue code only. +- Don't assert against rendered HTML of inspection descriptions — check the + inspection's `problem description` message key instead. +- Don't start a real Temporal server from tests; `StarterServerService` should + be tested with a stub `ProcessHandler` or by extracting the pure-logic parts. + +## Checklist before you commit a test + +- [ ] Test file mirrors the path of the class under test. +- [ ] Fixture files live under `src/test/resources/`, not inlined as strings. +- [ ] Expected messages come from `TemporalBundle.message(...)`. +- [ ] Inspection tests call `enableInspections(...)` in `setUp()`. +- [ ] No hard-coded OS paths, no sleeps, no network. +- [ ] `./gradlew test` passes locally. diff --git a/.claude/skills/temporal-activity/SKILL.md b/.claude/skills/temporal-activity/SKILL.md new file mode 100644 index 0000000..ebf0065 --- /dev/null +++ b/.claude/skills/temporal-activity/SKILL.md @@ -0,0 +1,80 @@ +--- +name: temporal-activity +description: Scaffold an official Temporal PHP Activity (interface + implementation) using #[ActivityInterface] / #[ActivityMethod] as recognized by the temporal-plugin. Use when the user asks to "create an activity", "new Temporal activity", or "add activity class". +--- + +# Temporal Activity (PHP) + +Generate a Temporal Activity pair following the PHP SDK conventions. The plugin's +`PhpActivityClassIndex`, `PhpActivityMethodIndex`, `PhpActivityMethodInspection`, +and `PhpActivityMethodUsageInspection` all key off these attributes. + +## Ask the user (only if unclear) + +- Fully qualified namespace (e.g. `App\Activity\Order`) +- Activity class name (PascalCase, e.g. `OrderActivity`) +- One or more methods (name, args, return type) +- Optional activity prefix (passed to `#[ActivityInterface(prefix: '...')]`) + +## Files to create + +### `/Interface.php` + +```php +/.php` + +```php +attempt` and deterministic IDs. +- Use `Activity::heartbeat($details)` inside long-running activities to support + cancellation and progress reporting. + +## After generation + +- Suggest wiring the activity into the worker bootstrap: + `$worker->registerActivity({{Name}}::class, fn ($ctx) => $container->get({{Name}}::class));` +- Remind the user to declare a stub in the calling workflow: + `Workflow::newActivityStub({{Name}}Interface::class, ActivityOptions::new()->withStartToCloseTimeout(30))`. diff --git a/.claude/skills/temporal-child-workflow/SKILL.md b/.claude/skills/temporal-child-workflow/SKILL.md new file mode 100644 index 0000000..e207f07 --- /dev/null +++ b/.claude/skills/temporal-child-workflow/SKILL.md @@ -0,0 +1,67 @@ +--- +name: temporal-child-workflow +description: Scaffold invocation of a child Temporal workflow from a parent workflow using ChildWorkflowOptions. Use when the user asks to "call a child workflow", "invoke child workflow", or "start child workflow". +--- + +# Temporal Child Workflow (PHP) + +Add code to a parent workflow that launches and awaits a child workflow. +The plugin recognizes `\Temporal\Workflow\ChildWorkflowStubInterface` +(`TemporalClasses.CHILD_WORKFLOW_STUB`). + +## Ask the user (only if unclear) + +- Parent workflow class +- Child workflow interface FQCN +- Whether the parent should `await` the child (typed stub) or fire-and-forget + (untyped `ChildWorkflowStub`) +- Parent-close policy (terminate / abandon / cancel) + +## Snippet — typed child stub (awaits result) + +```php +use Temporal\Workflow; +use Temporal\Workflow\ChildWorkflowOptions; +use Temporal\Workflow\ParentClosePolicy; + +$child = Workflow::newChildWorkflowStub( + {{Child}}Interface::class, + ChildWorkflowOptions::new() + ->withWorkflowId('{{childWorkflowId}}') + ->withTaskQueue('{{taskQueue}}') + ->withParentClosePolicy(ParentClosePolicy::POLICY_TERMINATE), +); + +$result = yield $child->{{method}}({{argNames}}); +``` + +## Snippet — untyped stub (fire-and-forget, signal-capable) + +```php +use Temporal\Workflow; +use Temporal\Workflow\ChildWorkflowStubInterface; + +$child = Workflow::newUntypedChildWorkflowStub( + '{{ChildWorkflowName}}', + ChildWorkflowOptions::new()->withWorkflowId('{{childWorkflowId}}'), +); + +yield $child->start({{argNames}}); +// $child->signal('someSignal', $payload); +``` + +## Conventions + +- Typed stubs enforce the child's interface — prefer them for normal call/await. +- The child is part of the parent's history; cancelling the parent propagates + to the child unless `ParentClosePolicy::POLICY_ABANDON` is used. +- Don't instantiate a child stub outside a `#[WorkflowMethod]` scope — the + `Workflow::` static helpers only work inside a running workflow. +- `ChildWorkflowOptions::withWorkflowId` is required for deterministic IDs; omit + for an auto-generated UUID. + +## After generation + +- Point out that launching the same child twice with the same workflow ID + triggers Temporal's duplicate-workflow handling (controlled by + `WorkflowIdReusePolicy`). diff --git a/.claude/skills/temporal-query/SKILL.md b/.claude/skills/temporal-query/SKILL.md new file mode 100644 index 0000000..78e7e40 --- /dev/null +++ b/.claude/skills/temporal-query/SKILL.md @@ -0,0 +1,56 @@ +--- +name: temporal-query +description: Add a #[QueryMethod] handler to an existing Temporal PHP Workflow. Use when the user asks to "add a query", "expose workflow state", or "register a workflow query". +--- + +# Temporal Query (PHP) + +Add a query handler to a workflow. Queries are **synchronous, read-only** peeks +into workflow state — they must be pure, deterministic, and cheap. + +## Ask the user (only if unclear) + +- Target workflow interface (FQCN) +- Query name (camelCase, e.g. `getStatus`) +- Return type + +## Changes + +### In `Interface.php` + +```php +use Temporal\Workflow\QueryMethod; + +#[QueryMethod(name: '{{queryName}}')] +public function {{queryName}}(): {{returnType}}; +``` + +### In `.php` + +```php +public function {{queryName}}(): {{returnType}} +{ + return $this->{{field}}; +} +``` + +## Conventions + +- **Read-only**: a query handler must **not** mutate workflow state, start + activities, or emit signals. Temporal will fail the query if it detects a + mutation. +- **Pure / deterministic**: derive results from the current workflow fields + only. +- Exceptions thrown from a query are delivered to the caller as a + `WorkflowQueryException`. +- Reserved/system query names (prefix `__`) are handled by Temporal itself — + don't collide with them. + +## After generation + +- Show how to invoke the query from a client: + +```php +$stub = $workflowClient->newRunningWorkflowStub({{Workflow}}Interface::class, $workflowId); +$status = $stub->{{queryName}}(); +``` diff --git a/.claude/skills/temporal-saga/SKILL.md b/.claude/skills/temporal-saga/SKILL.md new file mode 100644 index 0000000..bcc42bb --- /dev/null +++ b/.claude/skills/temporal-saga/SKILL.md @@ -0,0 +1,64 @@ +--- +name: temporal-saga +description: Scaffold a Temporal Saga compensation pattern inside a PHP workflow using the official \Temporal\Internal\Workflow\ActivityProxy / Saga helper. Use when the user asks for "saga", "compensation", or "rollback pattern". +--- + +# Temporal Saga / Compensation (PHP) + +Generate the classic Saga scaffold: a sequence of activities each paired with a +compensating action, rolled back in reverse order on failure. + +## Ask the user (only if unclear) + +- Host workflow class (FQCN) +- Ordered list of forward activities and their compensations +- Whether the saga should continue-on-failure or abort on first failure + +## Snippet — inside a `#[WorkflowMethod]` + +```php +use Temporal\Activity\ActivityOptions; +use Temporal\Workflow; + +$opts = ActivityOptions::new()->withStartToCloseTimeout(30); + +$bookFlight = Workflow::newActivityStub(FlightActivityInterface::class, $opts); +$bookHotel = Workflow::newActivityStub(HotelActivityInterface::class, $opts); +$bookCar = Workflow::newActivityStub(CarActivityInterface::class, $opts); + +$saga = new Workflow\Saga(); +$saga->setParallelCompensation(false); // true => run compensations concurrently + +try { + $flightRef = yield $bookFlight->reserve($tripId); + $saga->addCompensation(fn () => yield $bookFlight->cancel($flightRef)); + + $hotelRef = yield $bookHotel->reserve($tripId); + $saga->addCompensation(fn () => yield $bookHotel->cancel($hotelRef)); + + $carRef = yield $bookCar->reserve($tripId); + $saga->addCompensation(fn () => yield $bookCar->cancel($carRef)); + + return new TripReceipt($flightRef, $hotelRef, $carRef); +} catch (\Throwable $e) { + yield $saga->compensate(); + throw $e; +} +``` + +## Conventions + +- Register each compensation **after** its forward step succeeds — never before. +- Compensations run in **reverse** registration order by default. +- Compensations themselves should be idempotent — Temporal may retry the whole + compensation cycle after a worker crash. +- Prefer `setParallelCompensation(false)` for ordering-sensitive rollbacks + (flights must be cancelled before the payment is refunded, etc.). +- Don't `throw` from inside a compensation unless you want to abort the rollback + chain — Temporal surfaces the original exception after `compensate()` resolves. + +## After generation + +- Remind the user that each activity + its compensation should be modeled as + methods on the same `#[ActivityInterface]`, and that the plugin will + highlight any compensation method missing `#[ActivityMethod]`. diff --git a/.claude/skills/temporal-schedule/SKILL.md b/.claude/skills/temporal-schedule/SKILL.md new file mode 100644 index 0000000..91cb0d5 --- /dev/null +++ b/.claude/skills/temporal-schedule/SKILL.md @@ -0,0 +1,83 @@ +--- +name: temporal-schedule +description: Scaffold a Temporal Schedule (cron-like recurring workflow) using ScheduleClient and ScheduleSpec. Use when the user asks to "schedule a workflow", "cron workflow", or "recurring workflow". +--- + +# Temporal Schedule (PHP) + +Generate a script that creates a Temporal Schedule — the modern replacement for +cron-based workflows. A Schedule fires a configured workflow on a `ScheduleSpec` +(interval, calendar expression, or cron string). + +## Ask the user (only if unclear) + +- Workflow interface FQCN to schedule +- Task queue +- Schedule ID (e.g. `daily-report`) +- Cadence: interval (`PT1H`) **or** cron (`0 9 * * *`) **or** calendar spec + +## File to create + +### `schedule.php` (or `bin/schedule-{{workflow}}.php`) + +```php +withTaskQueue('{{taskQueue}}') + ->withWorkflowId('{{workflowIdPrefix}}-{{scheduledStartTime}}') + ->withInput([{{argNames}}]); + +$spec = ScheduleSpec::new() + ->withCronExpressions('{{cron}}'); // or ->withIntervalList('PT1H') + +$schedule = Schedule::new() + ->withAction($action) + ->withSpec($spec); + +$schedules->createSchedule( + schedule: $schedule, + options: ScheduleOptions::new()->withTriggerImmediately(false), + scheduleId: '{{scheduleId}}', +); +``` + +## Conventions + +- **One schedule per logical job**. Don't overload a schedule with multiple + unrelated workflows — create separate schedules instead. +- `ScheduleSpec` supports cron, interval, and calendar specs — pick one, mixing + is rarely what you want. +- Use `overlap policy` (`ScheduleOverlapPolicy`) to decide what happens when a + new trigger fires while the previous run is still executing (skip / buffer + one / buffer all / cancel other / terminate other / allow all). +- Use `pause(reason: ...)` on the schedule handle to temporarily halt firing + without deleting history. + +## After generation + +- Show the management commands the user is most likely to need next: + +```php +$handle = $schedules->getHandle('{{scheduleId}}'); +$handle->describe(); +$handle->pause('maintenance'); +$handle->unpause(); +$handle->trigger(); // one-off run, respects overlap policy +$handle->delete(); +``` diff --git a/.claude/skills/temporal-signal/SKILL.md b/.claude/skills/temporal-signal/SKILL.md new file mode 100644 index 0000000..e831f79 --- /dev/null +++ b/.claude/skills/temporal-signal/SKILL.md @@ -0,0 +1,72 @@ +--- +name: temporal-signal +description: Add a #[SignalMethod] handler to an existing Temporal PHP Workflow. Use when the user asks to "add a signal", "handle a signal", or "register a workflow signal". +--- + +# Temporal Signal (PHP) + +Add a signal handler to a workflow interface. Signals are **fire-and-forget** +messages sent to a running workflow; they must return `void` and should only +update internal state (the workflow loop will react via `Workflow::await()` / +polling state). + +## Ask the user (only if unclear) + +- Target workflow interface (FQCN) +- Signal name (camelCase, e.g. `cancelOrder`) +- Signal payload (args + types) + +## Changes + +### In `Interface.php` + +Add the method to the interface, alongside the existing `#[WorkflowMethod]`: + +```php +use Temporal\Workflow\SignalMethod; + +#[SignalMethod(name: '{{signalName}}')] +public function {{signalName}}({{args}}): void; +``` + +### In `.php` + +Implement the handler — store arguments on the workflow instance; do **not** +perform activities/I/O directly from a signal. + +```php +private array $pendingSignals = []; + +public function {{signalName}}({{args}}): void +{ + $this->pendingSignals[] = [{{argNames}}]; +} +``` + +Then in the main `#[WorkflowMethod]` loop, await/drain: + +```php +yield Workflow::await(fn () => $this->pendingSignals !== []); +$next = array_shift($this->pendingSignals); +``` + +## Conventions + +- Signals **must return `void`** — returning anything else is a contract + violation and will surface as a non-deterministic workflow. +- Signal handlers are **synchronous** from the workflow scheduler's perspective + — keep them trivial (append to a queue, flip a flag, etc.). +- Use `#[SignalMethod(name: '...')]` to decouple the external signal name from + the PHP method name (otherwise the method name is used). +- Signals are delivered at-least-once; handlers must be idempotent when that + matters. + +## After generation + +- Tell the user how to send the signal from a client: + +```php +$workflow = $workflowClient->newWorkflowStub({{Workflow}}Interface::class); +$run = $workflowClient->start($workflow, /* args */); +$workflow->{{signalName}}({{argNames}}); // or: $workflowClient->signalWorkflow(...) +``` diff --git a/.claude/skills/temporal-starter/SKILL.md b/.claude/skills/temporal-starter/SKILL.md new file mode 100644 index 0000000..9351b62 --- /dev/null +++ b/.claude/skills/temporal-starter/SKILL.md @@ -0,0 +1,71 @@ +--- +name: temporal-starter +description: Scaffold a Temporal PHP client starter that creates a WorkflowClient and kicks off a workflow run. Use when the user asks to "start a workflow", "create a client", or "add workflow starter". +--- + +# Temporal Workflow Starter (PHP) + +Generate a client-side script that connects to Temporal and starts (or signals) +a workflow. + +## Ask the user (only if unclear) + +- Target workflow interface FQCN +- Task queue the worker is listening on +- Desired `workflowId` strategy (deterministic vs. random) +- Arguments to pass to the workflow method +- Timeouts (`executionStartToCloseTimeout`, `runStartToCloseTimeout`) + +## File to create + +### `starter.php` (or `bin/start-{{workflow}}.php`) + +```php +newWorkflowStub( + {{Workflow}}Interface::class, + WorkflowOptions::new() + ->withTaskQueue('{{taskQueue}}') + ->withWorkflowId('{{workflowId}}') + ->withWorkflowExecutionTimeout('1 hour'), +); + +// Fire-and-forget start (returns immediately with WorkflowRun): +$run = $workflowClient->start($workflow, {{argNames}}); + +printf("Started %s / %s\n", $run->getExecution()->getID(), $run->getExecution()->getRunID()); + +// To wait for the result synchronously instead: +// $result = $workflow->{{method}}({{argNames}}); +// var_dump($result); +``` + +## Conventions + +- **One WorkflowClient per process** is fine; it holds the gRPC channel. +- Prefer deterministic `workflowId`s (e.g. `order-{$orderId}`) so duplicate + starts are rejected idempotently via + `WorkflowIdReusePolicy::REJECT_DUPLICATE`. +- `start()` returns immediately with a `WorkflowRunInterface`; calling the + annotated workflow method on the stub directly blocks until completion. +- For signal-with-start use `$workflowClient->startWithSignal(...)`. + +## After generation + +- Show how to query state later: + `$stub = $workflowClient->newRunningWorkflowStub({{Workflow}}Interface::class, $workflowId);` +- Suggest wiring this into the app's CLI (e.g. a Symfony/Laravel command) rather + than keeping a standalone script. diff --git a/.claude/skills/temporal-test/SKILL.md b/.claude/skills/temporal-test/SKILL.md new file mode 100644 index 0000000..a061ae5 --- /dev/null +++ b/.claude/skills/temporal-test/SKILL.md @@ -0,0 +1,95 @@ +--- +name: temporal-test +description: Scaffold a PHPUnit test for a Temporal workflow or activity using the temporal/sdk testing helpers. Use when the user asks to "test a workflow", "test an activity", or "add temporal test". +--- + +# Temporal Test Scaffold (PHP) + +Generate a PHPUnit test that exercises a workflow or activity without booting a +real Temporal server. Uses the `Temporal\Testing` helpers shipped with the PHP +SDK (`WorkflowEnvironment`, `ActivityMocker`). + +## Ask the user (only if unclear) + +- What is under test — a workflow, an activity, or an end-to-end flow? +- Target FQCN +- The PHPUnit test directory (`tests/Unit/...` vs `tests/Integration/...`) + +## Snippet — workflow test with mocked activities + +```php +env = WorkflowEnvironment::startTimeSkipping(); + $this->mocker = new ActivityMocker(); + } + + protected function tearDown(): void + { + $this->mocker->clear(); + $this->env->stop(); + } + + public function testHappyPath(): void + { + $this->mocker->expectCompletion('{{ActivityName}}.{{method}}', 'fake-result'); + + $client = $this->env->getWorkflowClient(); + $workflow = $client->newWorkflowStub({{Workflow}}Interface::class); + + $result = $workflow->{{method}}({{argNames}}); + + self::assertSame('fake-result', $result); + } +} +``` + +## Snippet — activity unit test (pure PHPUnit, no Temporal server) + +Activities are plain PHP classes — just instantiate and call. + +```php +final class {{Activity}}Test extends TestCase +{ + public function testDoWork(): void + { + $activity = new {{Activity}}(/* deps */); + + $result = $activity->{{method}}({{argNames}}); + + self::assertSame({{expected}}, $result); + } +} +``` + +## Conventions + +- Prefer `WorkflowEnvironment::startTimeSkipping()` so `Workflow::timer()` and + cron-style waits resolve instantly. +- Use `ActivityMocker` to stub out activity results per test — don't spin up a + real activity worker in unit tests. +- Keep activity tests in `tests/Unit/Activity/...` and workflow tests in + `tests/Integration/Workflow/...` (or mirror your project's existing layout). +- The IntelliJ plugin's `TemporalTypeProvider` resolves yield types for + generator-style workflow bodies, so autocompletion inside tests is accurate. + +## After generation + +- Remind the user to add `temporal/sdk` testing deps if not yet installed: + `composer require --dev temporal/sdk`. +- Wire the test into `phpunit.xml.dist` (``). diff --git a/.claude/skills/temporal-update/SKILL.md b/.claude/skills/temporal-update/SKILL.md new file mode 100644 index 0000000..badaf23 --- /dev/null +++ b/.claude/skills/temporal-update/SKILL.md @@ -0,0 +1,71 @@ +--- +name: temporal-update +description: Add a #[UpdateMethod] handler (and optional #[UpdateValidatorMethod]) to an existing Temporal PHP Workflow. Use when the user asks to "add an update", "add update handler", or "validate workflow update". +--- + +# Temporal Update (PHP) + +Updates are synchronous, validated, state-mutating RPCs against a running +workflow. Unlike signals, they **can return a value**. They are the recommended +primitive for request/response interactions with a workflow. + +## Ask the user (only if unclear) + +- Target workflow interface (FQCN) +- Update name (camelCase, e.g. `approveOrder`) +- Arguments + return type +- Whether a validator is needed (recommended for anything with preconditions) + +## Changes + +### In `Interface.php` + +```php +use Temporal\Workflow\UpdateMethod; +use Temporal\Workflow\UpdateValidatorMethod; + +#[UpdateMethod(name: '{{updateName}}')] +public function {{updateName}}({{args}}): {{returnType}}; + +#[UpdateValidatorMethod(forUpdate: '{{updateName}}')] +public function validate{{UpdateName}}({{args}}): void; +``` + +### In `.php` + +```php +public function validate{{UpdateName}}({{args}}): void +{ + // Throw \Temporal\Exception\Failure\ApplicationFailure if invalid. + // Must be pure / read-only — no state mutation, no activities. + if (!$this->canAccept({{argNames}})) { + throw new \InvalidArgumentException('Cannot accept update right now'); + } +} + +public function {{updateName}}({{args}}): {{returnType}} +{ + // Mutate state and/or yield to activities. + $this->{{field}} = {{argNames}}; + return {{returnExpr}}; +} +``` + +## Conventions + +- Validators must be **pure** and **read-only** — no state changes, no + activities, no signals. Rejecting an update in the validator is much cheaper + than rejecting it inside the handler. +- Validator name is linked to the update via `#[UpdateValidatorMethod(forUpdate: '...')]`. +- The handler itself may `yield` activities and mutate state. +- Updates preserve ordering with signals and other updates in the workflow + history. + +## After generation + +- Show the call site: + +```php +$stub = $workflowClient->newRunningWorkflowStub({{Workflow}}Interface::class, $workflowId); +$result = $stub->{{updateName}}({{argNames}}); +``` diff --git a/.claude/skills/temporal-worker/SKILL.md b/.claude/skills/temporal-worker/SKILL.md new file mode 100644 index 0000000..c94351c --- /dev/null +++ b/.claude/skills/temporal-worker/SKILL.md @@ -0,0 +1,89 @@ +--- +name: temporal-worker +description: Scaffold a Temporal PHP worker bootstrap (worker.php) that registers workflows and activities with a WorkerFactory. Use when the user asks to "create a worker", "set up temporal worker", or "add worker bootstrap". +--- + +# Temporal Worker Bootstrap (PHP) + +Generate an entry-point script that boots a Temporal worker process using +RoadRunner + the Temporal PHP SDK. + +## Ask the user (only if unclear) + +- Task queue name (e.g. `default`, `orders`) +- List of workflow FQCNs to register +- List of activity FQCNs to register +- Whether a DI container is in use (PSR-11) and where it is wired up + +## Files to create + +### `worker.php` (project root) + +```php +newWorker( + taskQueue: '{{taskQueue}}', +); + +// --- Workflows --------------------------------------------------------------- +// Workflows are stateless from the worker's perspective — Temporal instantiates +// them per-run; register the class name. +$worker->registerWorkflowTypes( + {{Workflow1}}::class, + // {{Workflow2}}::class, +); + +// --- Activities -------------------------------------------------------------- +// Activities are real objects — wire them through your container so +// constructor dependencies resolve. +$worker->registerActivity( + {{Activity1}}::class, + // Optional factory: fn (ReflectionClass $r) => $container->get($r->getName()), +); + +// Start the worker; this blocks and serves RR requests. +$factory->run(); +``` + +### `.rr.yaml` (if missing — ask before overwriting) + +```yaml +version: '3' + +rpc: + listen: tcp://127.0.0.1:6001 + +server: + command: "php worker.php" + +temporal: + address: ${TEMPORAL_ADDRESS:-127.0.0.1:7233} + activities: + num_workers: 4 +``` + +## Conventions + +- Register **workflow types** (class names) — the SDK instantiates a fresh one + for each workflow run. +- Register **activity instances** (or factories) — activities are real PHP + objects, usually constructed by your DI container. +- One worker = one task queue. Run multiple `newWorker(...)` calls on the same + factory if you need multiple task queues in one process. +- Keep `worker.php` slim: the real wiring belongs in your container config. + +## After generation + +- Run `vendor/bin/rr serve` to start the worker locally. +- Remind the user that the plugin's run configuration (`TemporalConfigurationType`) + boots the Temporal dev server; the worker connects to whatever + `TEMPORAL_ADDRESS` resolves to. diff --git a/.claude/skills/temporal-workflow/SKILL.md b/.claude/skills/temporal-workflow/SKILL.md new file mode 100644 index 0000000..63cc16e --- /dev/null +++ b/.claude/skills/temporal-workflow/SKILL.md @@ -0,0 +1,90 @@ +--- +name: temporal-workflow +description: Scaffold an official Temporal PHP Workflow (interface + implementation) following the Temporal PHP SDK conventions recognized by the temporal-plugin. Use when the user asks to "create a workflow", "new Temporal workflow", "add workflow class", etc. +--- + +# Temporal Workflow (PHP) + +Generate a Temporal Workflow pair: a `#[WorkflowInterface]` contract plus a concrete +implementation. The plugin's indexes (`PhpWorkflowClassIndex`, +`PhpWorkflowMethodIndex`) and `TemporalTypeProvider` rely on these exact +annotations and FQCNs. + +## Ask the user (only if unclear) + +- Fully qualified namespace (e.g. `App\Workflow\Order`) +- Workflow name (PascalCase, e.g. `OrderWorkflow`) +- The primary business method name (e.g. `run`, `handle`, `execute`) +- Return type (`void`, a DTO, `\Generator`, etc.) +- Input arguments (name + type) + +## Files to create + +### `/Interface.php` + +```php +/.php` + +```php +withStartToCloseTimeout(30) + // ->withRetryOptions(RetryOptions::new()->withMaximumAttempts(3)), + // ); + // return yield $activity->doWork($input); + } +} +``` + +## Conventions + +- Always put the `#[WorkflowInterface]` and `#[WorkflowMethod]` attributes on the + **interface**. `WorkflowMethod::name` should default to the class short name. +- Implementation must be a **final** class that `implements` the interface. +- Keep workflow code **deterministic**: no direct I/O, no `random_*`, no `time()`. + Use `Workflow::now()`, `Workflow::getInfo()`, `Workflow::sideEffect()`, + `Workflow::mutableSideEffect()` instead. +- For waits use `yield Workflow::timer(...)` or `yield Workflow::await(...)`. +- For any long-running or non-deterministic work, call an **Activity** via + `Workflow::newActivityStub()`. + +## After generation + +- Mention that the plugin's `PhpWorkflowMethodInspection` / indexes will pick up + the new workflow automatically. +- If the user is adding this to an existing worker, remind them to register the + workflow class with `WorkerFactory` / `WorkerInterface::registerWorkflowTypes()`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7c0f254 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,125 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with the code in this repository. + +## Project Overview + +**temporal-plugin** is an IntelliJ Platform plugin that improves the Developer Experience (DX) for [Temporal.io](https://temporal.io/) users across all IDEs based on the IntelliJ Platform (IDEA Ultimate, PhpStorm, etc.). + +- **Plugin ID**: `com.github.xepozz.temporal` +- **Vendor**: Dmitrii Derepko (@xepozz) +- **Platform**: IntelliJ Platform 2025.1.1+ (since build 251) +- **Currently supported languages**: PHP (extensible to Go, Java, TypeScript, etc.) + +Temporal is an open-source durable execution solution for building scalable, distributed systems. + +## Build & Development + +This project uses **Gradle (Kotlin DSL)** with the IntelliJ Platform Gradle Plugin. + +- **JVM**: Java 21 +- **Kotlin**: 2.3.0 +- **Gradle**: 9.2.1 + +Common commands: + +```bash +./gradlew build # Build the plugin +./gradlew runIde # Run IDE with the plugin loaded (sandbox) +./gradlew test # Run tests +./gradlew verifyPlugin # Verify against supported IDE versions +./gradlew buildPlugin # Produce a distributable .zip +./gradlew runIdeForUiTests # Run IDE for UI/robot tests +./gradlew koverXmlReport # Generate code coverage report +``` + +Dependencies are declared through the Gradle Version Catalog (`gradle/libs.versions.toml`) and via Gradle properties in `gradle.properties` (`platformPlugins`, `platformBundledPlugins`, `platformBundledModules`). + +Key platform plugin dependencies (from `gradle.properties`): +- `com.jetbrains.php` (optional, config file `language-php.xml`) +- `com.github.xepozz.metastorm` (required) +- `com.jetbrains.hackathon.indices.viewer` + +## Architecture + +The base package is `com.github.xepozz.temporal`. The project is split into two top-level packages: + +### `common/` — language-agnostic +- `extensionPoints/` — EP interfaces (`Workflow`, `Activity`) +- `model/` — common data classes (`Workflow`, `Activity`) +- `index/` — base indexing classes +- `endpoints/` — `microservices.endpointsProvider` implementations +- `run/` — Run configuration type for the Temporal dev server +- `uiViewer/` — Tool window (Web UI tab + Terminal tab), `StarterServerService`, actions +- `configuration/` — Settings (`TemporalExecutable`, settings UI, version checker) + +### `languages//` — language adapters +- `languages/php/` — the only adapter today + - `TemporalClasses.kt` — constants for Temporal PHP FQCNs/attributes + - `mixin.kt` — extension fns (`isActivity`, `isWorkflow`, `hasAttribute`, ...) + - `endpoints/PhpActivity.kt`, `endpoints/PhpWorkflow.kt` — EP impls + - `index/` — `PhpWorkflowClassIndex`, `PhpWorkflowMethodIndex`, `PhpActivityClassIndex`, `PhpActivityMethodIndex` + - `inspections/` — `PhpActivityMethodInspection`, `PhpActivityMethodUsageInspection`, quick fixes + - `TemporalTypeProvider.kt` — PHP type provider (resolves workflow/activity return types, React Promise, generator `yield`) + +### Plugin descriptors +- `src/main/resources/META-INF/plugin.xml` — core descriptor (EP definitions, common extensions, tool window, run config, endpoints, settings) +- `src/main/resources/META-INF/language-php.xml` — loaded only when `com.jetbrains.php` is available; wires PHP adapters, indexes, inspections, type provider + +## Extension Point Pattern + +When adding a feature that should work across languages, follow these five steps (see `CONTRIBUTING.md` for more detail): + +1. **Define the EP interface** in `common/extensionPoints/` (e.g., `Workflow`). +2. **Register the EP** in `plugin.xml` under `` with `dynamic="true"` and a name starting with `com.github.xepozz.temporal`. +3. **Implement a shared provider** (e.g., `CompletionProvider`, inspection, etc.) in `common/`, iterating over the EP via `ACTIVITY_EP.lazyDumbAwareExtensions(project)`. +4. **Implement the language adapter** in `languages//` (prefix the class with the language name, e.g., `PhpActivity`). +5. **Register the adapter** in the language-specific XML (e.g., `language-php.xml`) under `defaultExtensionNs="com.github.xepozz.temporal"`. + +## Temporal PHP SDK References + +The plugin recognizes these PHP FQCNs/attributes (see `languages/php/TemporalClasses.kt`): + +| Constant | FQCN | +|---|---| +| `ACTIVITY` | `\Temporal\Activity\ActivityInterface` | +| `ACTIVITY_METHOD` | `\Temporal\Activity\ActivityMethod` | +| `WORKFLOW` | `\Temporal\Workflow\WorkflowInterface` | +| `WORKFLOW_METHOD` | `\Temporal\Workflow\WorkflowMethod` | +| `WORKFLOW_INIT` | `\Temporal\Workflow\WorkflowInit` | +| `SIGNAL_METHOD` | `\Temporal\Workflow\SignalMethod` | +| `QUERY_METHOD` | `\Temporal\Workflow\QueryMethod` | +| `UPDATE_METHOD` | `\Temporal\Workflow\UpdateMethod` | +| `UPDATE_VALIDATOR_METHOD` | `\Temporal\Workflow\UpdateValidatorMethod` | +| `CHILD_WORKFLOW_STUB` | `\Temporal\Workflow\ChildWorkflowStubInterface` | + +## Coding Standards + +- **Kotlin first** — all new code in Kotlin; prefer `data class`/idiomatic Kotlin. +- **Performance** — use `CachedValue`, `DumbService.isDumb()`, and `lazyDumbAwareExtensions(project)` (not `extensionList`) when iterating EPs. +- **PHP PSI** — prefer the helpers in `languages/php/mixin.kt` (`PhpClass.isActivity()`, `Method.isWorkflow()`, `hasAttribute(fqn)`, ...) over manual PSI traversal. Note that `Method.isActivity/isWorkflow` are tolerant: they return `true` for a public, non-static, non-abstract method inside an Activity/Workflow class even without the explicit `#[ActivityMethod]`/`#[WorkflowMethod]` attribute. +- **Consistency** — mirror the PHP sub-package layout when adding new languages (e.g., `languages/go/navigation` mirrors `languages/php/navigation`). +- **Naming** — EP interfaces are named after the entity (`Workflow`, `Activity`), not suffixed with `EP`. Adapter classes are prefixed with the language (`Php...`). +- **Namespaces** — all EP names and IDs start with `com.github.xepozz.temporal`. +- **Minimal changes** — implement the smallest change that satisfies the requirement without breaking the architecture. + +## i18n + +All user-visible strings live in `src/main/resources/messages/TemporalBundle.properties` and are accessed via `TemporalBundle`. Inspection descriptions live in `src/main/resources/inspectionDescriptions/*.html`. + +## Skills + +Project-specific Claude Code skills are available under `.claude/skills/`. They scaffold official Temporal PHP SDK patterns (workflow, activity, signal, query, update, worker, saga, child workflow). Invoke via `/`. + +## Useful Paths + +| Topic | Path | +|---|---| +| Plugin descriptor | `src/main/resources/META-INF/plugin.xml` | +| PHP descriptor | `src/main/resources/META-INF/language-php.xml` | +| PHP FQCN constants | `src/main/kotlin/com/github/xepozz/temporal/languages/php/TemporalClasses.kt` | +| PHP PSI helpers | `src/main/kotlin/com/github/xepozz/temporal/languages/php/mixin.kt` | +| Extension points | `src/main/kotlin/com/github/xepozz/temporal/common/extensionPoints/` | +| Run configuration | `src/main/kotlin/com/github/xepozz/temporal/common/run/` | +| Tool window / Web UI | `src/main/kotlin/com/github/xepozz/temporal/common/uiViewer/` | +| Architecture guide | `CONTRIBUTING.md` | diff --git a/build.gradle.kts b/build.gradle.kts index 9c026e0..353c10d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -36,7 +36,9 @@ dependencies { // IntelliJ Platform Gradle Plugin Dependencies Extension - read more: https://plugins.jetbrains.com/docs/intellij/tools-intellij-platform-gradle-plugin-dependencies-extension.html intellijPlatform { - intellijIdea(providers.gradleProperty("platformVersion")) + // Use PhpStorm as the target IDE: it bundles PHP support so that + // both runIde and tests have the PHP file type / PSI registered. + phpstorm(providers.gradleProperty("platformVersion")) // Plugin Dependencies. Uses `platformBundledPlugins` property from the gradle.properties file for bundled IntelliJ Platform plugins. bundledPlugins(providers.gradleProperty("platformBundledPlugins").map { it.split(',') }) diff --git a/gradle.properties b/gradle.properties index 2f719a1..c4deef7 100644 --- a/gradle.properties +++ b/gradle.properties @@ -14,9 +14,10 @@ platformVersion = 2025.1.1 # Plugin Dependencies -> https://plugins.jetbrains.com/docs/intellij/plugin-dependencies.html # Example: platformPlugins = com.jetbrains.php:203.4449.22, org.intellij.scala:2023.3.27@EAP -platformPlugins = com.jetbrains.php:251.23774.16,com.jetbrains.hackathon.indices.viewer:1.30,com.github.xepozz.metastorm:2025.1.26 +# PHP is bundled in the PhpStorm target (see `phpstorm(...)` in build.gradle.kts) — declare it as a bundled plugin. +platformPlugins = com.jetbrains.hackathon.indices.viewer:1.30,com.github.xepozz.metastorm:2025.1.26 # Example: platformBundledPlugins = com.intellij.java -platformBundledPlugins = +platformBundledPlugins = com.jetbrains.php # Example: platformBundledModules = intellij.spellchecker platformBundledModules = diff --git a/src/test/kotlin/com/github/xepozz/temporal/languages/php/MixinTest.kt b/src/test/kotlin/com/github/xepozz/temporal/languages/php/MixinTest.kt new file mode 100644 index 0000000..901a54d --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/temporal/languages/php/MixinTest.kt @@ -0,0 +1,81 @@ +package com.github.xepozz.temporal.languages.php + +import com.github.xepozz.temporal.testing.TemporalPhpTestCase +import com.intellij.psi.util.PsiTreeUtil +import com.jetbrains.php.lang.psi.PhpFile +import com.jetbrains.php.lang.psi.elements.Method +import com.jetbrains.php.lang.psi.elements.PhpClass + +class MixinTest : TemporalPhpTestCase() { + + private lateinit var classes: Map + + override fun setUp() { + super.setUp() + val file = myFixture.configureByFile("php/mixin/MixinFixtures.php") as PhpFile + classes = PsiTreeUtil + .findChildrenOfType(file, PhpClass::class.java) + .associateBy { it.name } + } + + fun testPhpClassIsActivityReflectsAttributePresence() { + assertTrue(classes["MyActivityInterface"]!!.isActivity()) + assertTrue(classes["ConcreteActivityClass"]!!.isActivity()) + assertFalse(classes["MyWorkflowInterface"]!!.isActivity()) + assertFalse(classes["ConcreteWorkflowClass"]!!.isActivity()) + assertFalse(classes["PlainClass"]!!.isActivity()) + } + + fun testPhpClassIsWorkflowReflectsAttributePresence() { + assertTrue(classes["MyWorkflowInterface"]!!.isWorkflow()) + assertTrue(classes["ConcreteWorkflowClass"]!!.isWorkflow()) + assertFalse(classes["MyActivityInterface"]!!.isWorkflow()) + assertFalse(classes["PlainClass"]!!.isWorkflow()) + } + + fun testHasAttributeMatchesTemporalClassFqns() { + val activity = classes["ConcreteActivityClass"]!! + assertTrue(activity.hasAttribute(TemporalClasses.ACTIVITY)) + assertFalse(activity.hasAttribute(TemporalClasses.WORKFLOW)) + + val workflow = classes["ConcreteWorkflowClass"]!! + assertTrue(workflow.hasAttribute(TemporalClasses.WORKFLOW)) + assertFalse(workflow.hasAttribute(TemporalClasses.ACTIVITY)) + + val plain = classes["PlainClass"]!! + assertFalse(plain.hasAttribute(TemporalClasses.ACTIVITY)) + assertFalse(plain.hasAttribute(TemporalClasses.WORKFLOW)) + } + + fun testMethodIsActivityIsTolerantForPublicInstanceMethodsInActivityClass() { + val klass = classes["ConcreteActivityClass"]!! + + assertTrue("explicit #[ActivityMethod]", klass.methodByName("withAttr").isActivity()) + assertTrue("public non-static method tolerated", klass.methodByName("withoutAttr").isActivity()) + } + + fun testMethodIsActivityExcludesStaticMagicAndNonPublicMethods() { + val klass = classes["ConcreteActivityClass"]!! + + assertFalse("static", klass.methodByName("staticMethod").isActivity()) + assertFalse("magic __construct", klass.methodByName("__construct").isActivity()) + assertFalse("non-public", klass.methodByName("protectedMethod").isActivity()) + } + + fun testMethodIsWorkflowIsTolerantForPublicInstanceMethodsInWorkflowClass() { + val klass = classes["ConcreteWorkflowClass"]!! + + assertTrue("explicit #[WorkflowMethod]", klass.methodByName("run").isWorkflow()) + assertTrue("tolerant", klass.methodByName("helperWithoutAttribute").isWorkflow()) + } + + fun testMethodIsActivityReturnsFalseOutsideOfActivityClass() { + val plainMethod = classes["PlainClass"]!!.methodByName("method") + assertFalse(plainMethod.isActivity()) + assertFalse(plainMethod.isWorkflow()) + } + + private fun PhpClass.methodByName(name: String): Method = + ownMethods.firstOrNull { it.name == name } + ?: throw AssertionError("Method '$name' not found in ${this.name}; available: ${ownMethods.joinToString { it.name }}") +} diff --git a/src/test/kotlin/com/github/xepozz/temporal/languages/php/index/PhpActivityClassIndexTest.kt b/src/test/kotlin/com/github/xepozz/temporal/languages/php/index/PhpActivityClassIndexTest.kt new file mode 100644 index 0000000..7b942f4 --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/temporal/languages/php/index/PhpActivityClassIndexTest.kt @@ -0,0 +1,47 @@ +package com.github.xepozz.temporal.languages.php.index + +import com.github.xepozz.temporal.testing.TemporalPhpTestCase +import com.intellij.psi.search.GlobalSearchScope +import com.intellij.util.indexing.FileBasedIndex + +class PhpActivityClassIndexTest : TemporalPhpTestCase() { + + fun testIndexesOnlyClassesWithActivityInterfaceAttribute() { + myFixture.copyFileToProject( + "php/index/OrderActivityInterface.php", + "src/Activity/OrderActivity.php", + ) + myFixture.copyFileToProject( + "php/index/PaymentActivityInterface.php", + "src/Activity/PaymentActivity.php", + ) + myFixture.copyFileToProject( + "php/index/NotAnActivity.php", + "src/Service/NotAnActivity.php", + ) + + val keys = collectLiveKeys() + + assertContainsElements( + keys, + "\\App\\Activity\\OrderActivity", + "\\App\\Activity\\PaymentActivity", + ) + assertDoesntContain(keys, "\\App\\Service\\Foo", "\\App\\Service\\BarInterface") + } + + fun testEmptyProjectHasNoActivityClasses() { + val keys = collectLiveKeys() + + assertEmpty(keys) + } + + private fun collectLiveKeys(): List { + val idx = FileBasedIndex.getInstance() + val scope = GlobalSearchScope.projectScope(project) + return idx.getAllKeys(PhpActivityClassIndex.NAME, project) + .filter { key -> + idx.getContainingFiles(PhpActivityClassIndex.NAME, key, scope).isNotEmpty() + } + } +} diff --git a/src/test/kotlin/com/github/xepozz/temporal/languages/php/index/PhpActivityMethodIndexTest.kt b/src/test/kotlin/com/github/xepozz/temporal/languages/php/index/PhpActivityMethodIndexTest.kt new file mode 100644 index 0000000..2295291 --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/temporal/languages/php/index/PhpActivityMethodIndexTest.kt @@ -0,0 +1,57 @@ +package com.github.xepozz.temporal.languages.php.index + +import com.github.xepozz.temporal.testing.TemporalPhpTestCase +import com.intellij.psi.search.GlobalSearchScope +import com.intellij.util.indexing.FileBasedIndex + +class PhpActivityMethodIndexTest : TemporalPhpTestCase() { + + fun testIndexesEveryPublicConcreteMethodInsideAnActivityClass() { + myFixture.copyFileToProject( + "php/index/OrderActivityInterface.php", + "src/Activity/OrderActivity.php", + ) + myFixture.copyFileToProject( + "php/index/PaymentActivityInterface.php", + "src/Activity/PaymentActivity.php", + ) + + val keys = collectLiveKeys() + + // Method.isActivity() is tolerant — public non-static non-abstract methods + // in a class carrying #[ActivityInterface] are indexed whether or not + // they declare #[ActivityMethod] themselves. + assertContainsElements( + keys, + "\\App\\Activity\\OrderActivity::reserve", + "\\App\\Activity\\OrderActivity::cancel", + "\\App\\Activity\\OrderActivity::track", + "\\App\\Activity\\PaymentActivity::charge", + "\\App\\Activity\\PaymentActivity::refund", + ) + } + + fun testDoesNotIndexMethodsOfNonActivityClasses() { + myFixture.copyFileToProject( + "php/index/NotAnActivity.php", + "src/Service/NotAnActivity.php", + ) + + val keys = collectLiveKeys() + + assertDoesntContain( + keys, + "\\App\\Service\\Foo::bar", + "\\App\\Service\\BarInterface::baz", + ) + } + + private fun collectLiveKeys(): List { + val idx = FileBasedIndex.getInstance() + val scope = GlobalSearchScope.projectScope(project) + return idx.getAllKeys(PhpActivityMethodIndex.NAME, project) + .filter { key -> + idx.getContainingFiles(PhpActivityMethodIndex.NAME, key, scope).isNotEmpty() + } + } +} diff --git a/src/test/kotlin/com/github/xepozz/temporal/languages/php/index/PhpWorkflowClassIndexTest.kt b/src/test/kotlin/com/github/xepozz/temporal/languages/php/index/PhpWorkflowClassIndexTest.kt new file mode 100644 index 0000000..8e01518 --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/temporal/languages/php/index/PhpWorkflowClassIndexTest.kt @@ -0,0 +1,41 @@ +package com.github.xepozz.temporal.languages.php.index + +import com.github.xepozz.temporal.testing.TemporalPhpTestCase +import com.intellij.psi.search.GlobalSearchScope +import com.intellij.util.indexing.FileBasedIndex + +class PhpWorkflowClassIndexTest : TemporalPhpTestCase() { + + fun testIndexesOnlyClassesWithWorkflowInterfaceAttribute() { + myFixture.copyFileToProject( + "php/index/OrderWorkflowInterface.php", + "src/Workflow/OrderWorkflow.php", + ) + myFixture.copyFileToProject( + "php/index/ReportWorkflowInterface.php", + "src/Workflow/ReportWorkflow.php", + ) + myFixture.copyFileToProject( + "php/index/NotAnActivity.php", + "src/Service/NotAnActivity.php", + ) + + val keys = collectLiveKeys() + + assertContainsElements( + keys, + "\\App\\Workflow\\OrderWorkflow", + "\\App\\Workflow\\ReportWorkflow", + ) + assertDoesntContain(keys, "\\App\\Service\\Foo", "\\App\\Service\\BarInterface") + } + + private fun collectLiveKeys(): List { + val idx = FileBasedIndex.getInstance() + val scope = GlobalSearchScope.projectScope(project) + return idx.getAllKeys(PhpWorkflowClassIndex.NAME, project) + .filter { key -> + idx.getContainingFiles(PhpWorkflowClassIndex.NAME, key, scope).isNotEmpty() + } + } +} diff --git a/src/test/kotlin/com/github/xepozz/temporal/languages/php/index/PhpWorkflowMethodIndexTest.kt b/src/test/kotlin/com/github/xepozz/temporal/languages/php/index/PhpWorkflowMethodIndexTest.kt new file mode 100644 index 0000000..66e0e7b --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/temporal/languages/php/index/PhpWorkflowMethodIndexTest.kt @@ -0,0 +1,52 @@ +package com.github.xepozz.temporal.languages.php.index + +import com.github.xepozz.temporal.testing.TemporalPhpTestCase +import com.intellij.psi.search.GlobalSearchScope +import com.intellij.util.indexing.FileBasedIndex + +class PhpWorkflowMethodIndexTest : TemporalPhpTestCase() { + + fun testIndexesMethodsOfWorkflowClasses() { + myFixture.copyFileToProject( + "php/index/OrderWorkflowInterface.php", + "src/Workflow/OrderWorkflow.php", + ) + myFixture.copyFileToProject( + "php/index/ReportWorkflowInterface.php", + "src/Workflow/ReportWorkflow.php", + ) + + val keys = collectLiveKeys() + + assertContainsElements( + keys, + "\\App\\Workflow\\OrderWorkflow::run", + "\\App\\Workflow\\OrderWorkflow::cancel", + "\\App\\Workflow\\ReportWorkflow::generate", + ) + } + + fun testDoesNotIndexMethodsOfNonWorkflowClasses() { + myFixture.copyFileToProject( + "php/index/NotAnActivity.php", + "src/Service/NotAnActivity.php", + ) + + val keys = collectLiveKeys() + + assertDoesntContain( + keys, + "\\App\\Service\\Foo::bar", + "\\App\\Service\\BarInterface::baz", + ) + } + + private fun collectLiveKeys(): List { + val idx = FileBasedIndex.getInstance() + val scope = GlobalSearchScope.projectScope(project) + return idx.getAllKeys(PhpWorkflowMethodIndex.NAME, project) + .filter { key -> + idx.getContainingFiles(PhpWorkflowMethodIndex.NAME, key, scope).isNotEmpty() + } + } +} diff --git a/src/test/kotlin/com/github/xepozz/temporal/languages/php/inspections/PhpActivityMethodInspectionTest.kt b/src/test/kotlin/com/github/xepozz/temporal/languages/php/inspections/PhpActivityMethodInspectionTest.kt new file mode 100644 index 0000000..fd19c93 --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/temporal/languages/php/inspections/PhpActivityMethodInspectionTest.kt @@ -0,0 +1,44 @@ +package com.github.xepozz.temporal.languages.php.inspections + +import com.github.xepozz.temporal.TemporalBundle +import com.github.xepozz.temporal.testing.TemporalPhpTestCase + +class PhpActivityMethodInspectionTest : TemporalPhpTestCase() { + + override fun setUp() { + super.setUp() + myFixture.enableInspections(PhpActivityMethodInspection::class.java) + } + + fun testWarnsOnActivityMethodWithoutAttribute() { + myFixture.configureByFile("php/inspections/activityMethodMissingAttribute.php") + + myFixture.checkHighlighting(/* checkWarnings = */ true, false, false) + } + + fun testNoWarningsWhenEveryMethodIsAnnotated() { + myFixture.configureByFile("php/inspections/activityMethodAllAnnotated.php") + + myFixture.checkHighlighting(true, false, false) + } + + fun testIgnoresClassesWithoutActivityInterfaceAttribute() { + myFixture.configureByFile("php/inspections/nonActivityClass.php") + + myFixture.checkHighlighting(true, false, false) + } + + fun testQuickFixAddsActivityMethodAttribute() { + myFixture.configureByFile("php/inspections/activityMethodMissingAttributeFix.php") + + val expected = TemporalBundle.message("inspection.php.activity.method.attribute.missing.quick.fix") + val fix = myFixture.getAllQuickFixes().firstOrNull { it.text == expected } + ?: throw AssertionError( + "No quick fix named '$expected'; available: " + + myFixture.getAllQuickFixes().joinToString { it.text } + ) + myFixture.launchAction(fix) + + myFixture.checkResultByFile("php/inspections/activityMethodMissingAttributeFix.after.php") + } +} diff --git a/src/test/kotlin/com/github/xepozz/temporal/languages/php/inspections/PhpActivityMethodUsageInspectionTest.kt b/src/test/kotlin/com/github/xepozz/temporal/languages/php/inspections/PhpActivityMethodUsageInspectionTest.kt new file mode 100644 index 0000000..50c8a03 --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/temporal/languages/php/inspections/PhpActivityMethodUsageInspectionTest.kt @@ -0,0 +1,29 @@ +package com.github.xepozz.temporal.languages.php.inspections + +import com.github.xepozz.temporal.testing.TemporalPhpTestCase + +class PhpActivityMethodUsageInspectionTest : TemporalPhpTestCase() { + + override fun setUp() { + super.setUp() + myFixture.enableInspections(PhpActivityMethodUsageInspection::class.java) + } + + fun testWarnsAtCallSiteWhenTargetMethodLacksActivityMethodAttribute() { + myFixture.configureByFile("php/inspections/activityMethodUsageMissing.php") + + myFixture.checkHighlighting(/* checkWarnings = */ true, false, false) + } + + fun testNoWarningsWhenEveryTargetMethodIsAnnotated() { + myFixture.configureByFile("php/inspections/activityMethodUsageOk.php") + + myFixture.checkHighlighting(true, false, false) + } + + fun testIgnoresCallsToNonActivityClasses() { + myFixture.configureByFile("php/inspections/nonActivityUsage.php") + + myFixture.checkHighlighting(true, false, false) + } +} diff --git a/src/test/kotlin/com/github/xepozz/temporal/testing/TemporalPhpTestCase.kt b/src/test/kotlin/com/github/xepozz/temporal/testing/TemporalPhpTestCase.kt new file mode 100644 index 0000000..9c6a7de --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/temporal/testing/TemporalPhpTestCase.kt @@ -0,0 +1,34 @@ +package com.github.xepozz.temporal.testing + +import com.intellij.openapi.vfs.newvfs.impl.VfsRootAccess +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.io.File + +/** + * Base class for tests that need the Temporal PHP stubs available in the project. + * + * The stub file declares `\Temporal\Activity\ActivityInterface`, + * `\Temporal\Activity\ActivityMethod`, `\Temporal\Workflow\WorkflowInterface`, + * etc. — so that `#[ActivityInterface]` / `#[WorkflowInterface]` / `#[ActivityMethod]` + * references in fixture files resolve to the same FQNs that + * [com.github.xepozz.temporal.languages.php.TemporalClasses] declares. + */ +abstract class TemporalPhpTestCase : BasePlatformTestCase() { + + override fun getTestDataPath(): String = File(TEST_DATA_ROOT).absolutePath + + override fun setUp() { + super.setUp() + // VFS restricts file access to a whitelisted set of roots during tests. + // Our test data lives outside the default allow-list, so we register it + // for the lifetime of the test. The root is released when + // `testRootDisposable` fires, keeping tests hermetic. + VfsRootAccess.allowRootAccess(testRootDisposable, testDataPath) + myFixture.copyFileToProject(STUBS_RELATIVE_PATH, STUBS_RELATIVE_PATH) + } + + companion object { + const val TEST_DATA_ROOT = "src/test/resources" + const val STUBS_RELATIVE_PATH = "stubs/Temporal.php" + } +} diff --git a/src/test/resources/php/index/NotAnActivity.php b/src/test/resources/php/index/NotAnActivity.php new file mode 100644 index 0000000..4b39304 --- /dev/null +++ b/src/test/resources/php/index/NotAnActivity.php @@ -0,0 +1,13 @@ +reserve(string $orderId): void + { + } + + #[ActivityMethod(name: 'cancel')] + public function cancel(string $orderId): void + { + } +} diff --git a/src/test/resources/php/inspections/activityMethodMissingAttributeFix.after.php b/src/test/resources/php/inspections/activityMethodMissingAttributeFix.after.php new file mode 100644 index 0000000..a8dd375 --- /dev/null +++ b/src/test/resources/php/inspections/activityMethodMissingAttributeFix.after.php @@ -0,0 +1,19 @@ +rve(string $orderId): void + { + } + + #[ActivityMethod(name: 'cancel')] + public function cancel(string $orderId): void + { + } +} diff --git a/src/test/resources/php/inspections/activityMethodUsageMissing.php b/src/test/resources/php/inspections/activityMethodUsageMissing.php new file mode 100644 index 0000000..405a5aa --- /dev/null +++ b/src/test/resources/php/inspections/activityMethodUsageMissing.php @@ -0,0 +1,29 @@ +charge(100); + + // OK — has ActivityMethod + $activity->refund('x'); +} diff --git a/src/test/resources/php/inspections/activityMethodUsageOk.php b/src/test/resources/php/inspections/activityMethodUsageOk.php new file mode 100644 index 0000000..8eab722 --- /dev/null +++ b/src/test/resources/php/inspections/activityMethodUsageOk.php @@ -0,0 +1,27 @@ +ship('order-1'); + $activity->track('order-1'); +} diff --git a/src/test/resources/php/inspections/nonActivityClass.php b/src/test/resources/php/inspections/nonActivityClass.php new file mode 100644 index 0000000..f871c5f --- /dev/null +++ b/src/test/resources/php/inspections/nonActivityClass.php @@ -0,0 +1,11 @@ +find('x'); + $repo->save('x'); +} diff --git a/src/test/resources/php/mixin/MixinFixtures.php b/src/test/resources/php/mixin/MixinFixtures.php new file mode 100644 index 0000000..2be80a6 --- /dev/null +++ b/src/test/resources/php/mixin/MixinFixtures.php @@ -0,0 +1,54 @@ +