diff --git a/CHANGELOG.md b/CHANGELOG.md index d91cfc2..784f794 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ### Added +- **Doctor diagnostics action** — new `DoctorAction` that collects and displays plugin setup diagnostics: + project root, Gemfile status, Ruby SDK path, docscribe gem version, daemon server state, and + settings. Accessible from Editor Popup DocScribe DocScribe Doctor. Reports actionable steps + for common issues (missing gem, no SDK, no Gemfile). - **Capability detection for docscribe version** — `performGemCheck()` now parses `--version` output and stores parsed version + capabilities (`serverMode` for ≥ 1.5.1). `ensureRunning()` skips server startup when version < 1.5.1, falling back directly to CLI. New `parseVersion()` utility diff --git a/src/main/kotlin/com/florexlabs/docscribe/actions/DoctorAction.kt b/src/main/kotlin/com/florexlabs/docscribe/actions/DoctorAction.kt new file mode 100644 index 0000000..425ce22 --- /dev/null +++ b/src/main/kotlin/com/florexlabs/docscribe/actions/DoctorAction.kt @@ -0,0 +1,191 @@ +package com.florexlabs.docscribe.actions + +import com.florexlabs.docscribe.runner.DocscribeDaemon +import com.florexlabs.docscribe.runner.DocscribeDaemon.DocscribeCapabilities +import com.florexlabs.docscribe.runner.DocscribeDaemon.DocscribeStatus +import com.florexlabs.docscribe.runner.DocscribeRunner +import com.florexlabs.docscribe.settings.DocscribeSettings +import com.intellij.notification.NotificationGroupManager +import com.intellij.notification.NotificationType +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.Project +import com.intellij.openapi.roots.ProjectRootManager +import java.io.File + +/** + * Diagnose DocScribe plugin setup and show a detailed report. + * + * Collects: project root, Gemfile, Ruby SDK, docscribe gem status/version, + * daemon server state, plugin settings. Calls none of the query methods + * trigger side effects — they read cached state. + */ +class DoctorAction : AnAction() { + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val report = buildReport(project) + notify(project, report) + } + + /** + * Enable action for any open project. + */ + override fun update(e: AnActionEvent) { + e.presentation.isEnabledAndVisible = e.project != null + } + + /** + * Always use a background thread for update checks. + */ + override fun getActionUpdateThread() = ActionUpdateThread.BGT + + /** + * Build a multi-line diagnostic report string. + * + * @param project The current project. + * @return Formatted diagnostic text with sections. + */ + private fun buildReport(project: Project): String { + val daemon = DocscribeDaemon.getInstance(project) + val basePath = project.basePath ?: "?" + val gemRoot = DocscribeRunner.findProjectRoot(basePath) + val rubyPath = daemon.getRubyPath() + val status = daemon.getDocscribeStatus() + val caps = daemon.getCapabilities() + + val lines = mutableListOf("=== DocScribe Diagnostics ===\n") + lines += projectInfoLines(basePath, gemRoot) + lines += "" + lines += rubySdkLines(project, daemon) + lines += "" + lines += gemInfoLines(status, caps) + lines += "" + lines += daemonInfoLines(project, status, caps) + lines += "" + val settings = DocscribeSettings.getInstance() + lines += listOf("Settings:", " hideCommentsByDefault = ${settings.hideCommentsByDefault}") + lines += "" + lines += issuesSummaryLines(rubyPath, gemRoot, status) + return lines.joinToString("\n") + } + + /** + * Project root and Gemfile status. + */ + private fun projectInfoLines( + basePath: String, + gemRoot: String?, + ): List { + val lines = mutableListOf("Project root: $basePath") + val gemFile = if (gemRoot != null) File(gemRoot, "Gemfile") else null + val gemStatus = + when { + gemFile != null && gemFile.exists() -> "found ($gemFile)" + gemFile != null -> "Gemfile path exists but file missing: $gemFile" + else -> "not found (no Gemfile in tree)" + } + lines += "Gemfile: $gemStatus" + return lines + } + + /** + * IDE Ruby SDK and resolved Ruby binary path. + */ + private fun rubySdkLines( + project: Project, + daemon: DocscribeDaemon, + ): List { + val sdk = ProjectRootManager.getInstance(project).projectSdk + val sdkInfo = + if (sdk != null) "${sdk.name} (home: ${sdk.homePath ?: "?"})" else "not configured" + val rubyPath = daemon.getRubyPath() + return listOf("IDE Ruby SDK: $sdkInfo", "Ruby binary: ${rubyPath ?: "not found"}") + } + + /** + * docscribe gem availability, version, and server mode support. + */ + private fun gemInfoLines( + status: DocscribeStatus, + caps: DocscribeCapabilities?, + ): List { + val lines = mutableListOf("docscribe gem: ${statusLabel(status)}") + if (status == DocscribeStatus.UNCHECKED) { + lines += " (run any DocScribe action to trigger gem detection)" + } + if (caps != null) { + lines += " Version: ${caps.version}" + lines += " Server mode: ${if (caps.serverMode) "supported (>= 1.5.1)" else "not available (< 1.5.1)"}" + } + return lines + } + + /** + * Daemon server state and fallback explanation. + */ + private fun daemonInfoLines( + project: Project, + status: DocscribeStatus, + caps: DocscribeCapabilities?, + ): List { + val daemon = DocscribeDaemon.getInstance(project) + val running = daemon.isServerRunning() + val lines = mutableListOf("Daemon server: ${if (running) "running" else "stopped"}") + if (status == DocscribeStatus.AVAILABLE) { + val shouldRun = caps?.serverMode == true + when { + shouldRun && !running -> lines += " (will start on next DocScribe action)" + !shouldRun -> lines += " (server not available — using CLI fallback)" + } + } else if (status == DocscribeStatus.MISSING) { + lines += " (gem not installed — see 'docscribe gem' section above)" + } + return lines + } + + /** + * Collect identified issues with actionable steps. + */ + private fun issuesSummaryLines( + rubyPath: String?, + gemRoot: String?, + status: DocscribeStatus, + ): List { + val issues = mutableListOf() + if (rubyPath == null) issues += "No Ruby SDK configured or found on PATH." + if (gemRoot == null) issues += "No Gemfile found in project tree." + if (status == DocscribeStatus.MISSING) { + issues += "docscribe gem is not installed. Add 'gem \"docscribe\"' to Gemfile and run 'bundle install'." + } + return if (issues.isEmpty()) { + listOf("Status: OK — all systems nominal.") + } else { + listOf("Issues found:") + issues.map { " - $it" } + } + } + + /** + * Human-readable label for a [DocscribeStatus] value. + */ + private fun statusLabel(status: DocscribeStatus): String = + when (status) { + DocscribeStatus.UNCHECKED -> "not yet checked" + DocscribeStatus.AVAILABLE -> "AVAILABLE" + DocscribeStatus.MISSING -> "MISSING" + } + + /** + * Show a DocScribe information notification. + * + * @param project The project to show the notification in. + * @param content The multi-line diagnostic report. + */ + private fun notify( + project: Project, + content: String, + ) { + val group = NotificationGroupManager.getInstance().getNotificationGroup("DocScribe") + group.createNotification(content, NotificationType.INFORMATION).notify(project) + } +} diff --git a/src/main/kotlin/com/florexlabs/docscribe/runner/DocscribeDaemon.kt b/src/main/kotlin/com/florexlabs/docscribe/runner/DocscribeDaemon.kt index 6c8c933..ca9c6b9 100644 --- a/src/main/kotlin/com/florexlabs/docscribe/runner/DocscribeDaemon.kt +++ b/src/main/kotlin/com/florexlabs/docscribe/runner/DocscribeDaemon.kt @@ -110,6 +110,38 @@ class DocscribeDaemon( val process: Process, ) + // -- Public diagnostics accessors -- + + /** + * The Ruby executable path resolved for this project. + * + * @return Absolute path to Ruby, or `null` if not found. + */ + fun getRubyPath(): String? = rubyCommand() + + /** + * Current docscribe gem availability status. + * + * @return [DocscribeStatus] — UNCHECKED, AVAILABLE, or MISSING. + */ + fun getDocscribeStatus(): DocscribeStatus = docscribeStatus + + /** + * Parsed docscribe capabilities (version + server mode support). + * + * @return Capabilities if gem was detected, `null` otherwise. + */ + fun getCapabilities(): DocscribeCapabilities? = capabilities + + /** + * Whether the docscribe daemon server is currently running and alive. + * + * @return `true` if the server process is active. + */ + fun isServerRunning(): Boolean = server != null && alive + + // -- RPC execution -- + /** * Execute an RPC command against the server, falling back to CLI if needed. * diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index d24ef1a..aad7f9f 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -109,6 +109,11 @@ text="Update Types from RBS" description="Run docscribe update_types to refresh YARD docs from RBS signatures"/> + + @@ -120,6 +125,8 @@ + + diff --git a/src/test/kotlin/com/florexlabs/docscribe/actions/DoctorActionTest.kt b/src/test/kotlin/com/florexlabs/docscribe/actions/DoctorActionTest.kt new file mode 100644 index 0000000..153f98b --- /dev/null +++ b/src/test/kotlin/com/florexlabs/docscribe/actions/DoctorActionTest.kt @@ -0,0 +1,37 @@ +package com.florexlabs.docscribe.actions + +import com.intellij.openapi.actionSystem.ActionUiKind +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.actionSystem.Presentation +import com.intellij.openapi.actionSystem.impl.SimpleDataContext +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class DoctorActionTest : BasePlatformTestCase() { + private val action = DoctorAction() + + fun testActionIsEnabledWithProject() { + val dataContext = + SimpleDataContext + .builder() + .add(CommonDataKeys.PROJECT, myFixture.project) + .build() + val presentation = Presentation() + val event = AnActionEvent.createEvent(dataContext, presentation, "test", ActionUiKind.NONE, null) + action.update(event) + assertTrue(presentation.isEnabledAndVisible) + } + + fun testActionIsDisabledWithoutProject() { + val dataContext = SimpleDataContext.builder().build() + val presentation = Presentation() + val event = AnActionEvent.createEvent(dataContext, presentation, "test", ActionUiKind.NONE, null) + action.update(event) + assertFalse(presentation.isEnabledAndVisible) + } + + fun testActionUpdateThreadIsBGT() { + assertEquals(ActionUpdateThread.BGT, action.actionUpdateThread) + } +} diff --git a/src/test/kotlin/com/florexlabs/docscribe/runner/DocscribeDaemonGemTest.kt b/src/test/kotlin/com/florexlabs/docscribe/runner/DocscribeDaemonGemTest.kt index b4a3aff..9308916 100644 --- a/src/test/kotlin/com/florexlabs/docscribe/runner/DocscribeDaemonGemTest.kt +++ b/src/test/kotlin/com/florexlabs/docscribe/runner/DocscribeDaemonGemTest.kt @@ -101,7 +101,7 @@ class DocscribeDaemonGemTest : BasePlatformTestCase() { // No server mode — should trigger CLI fallback, which needs a Gemfile // Since there's no Gemfile in test, ensureRunning returns null, // fallback() sees MISSING stub but status is AVAILABLE… - // Actually this tests the path through execute → ensureRunning → null → fallback + // Actually this tests the path through execute -> ensureRunning -> null -> fallback val result = daemon.execute("check", file = "test.rb", projectDir = "/tmp") // Without a Gemfile, fallback may fail — just ensure no crash assertNotNull(result)