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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
191 changes: 191 additions & 0 deletions src/main/kotlin/com/florexlabs/docscribe/actions/DoctorAction.kt
Original file line number Diff line number Diff line change
@@ -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<String> {
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<String> {
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<String> {
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<String> {
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<String> {
val issues = mutableListOf<String>()
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)
}
}
32 changes: 32 additions & 0 deletions src/main/kotlin/com/florexlabs/docscribe/runner/DocscribeDaemon.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
7 changes: 7 additions & 0 deletions src/main/resources/META-INF/plugin.xml
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,11 @@
text="Update Types from RBS"
description="Run docscribe update_types to refresh YARD docs from RBS signatures"/>

<action id="com.florexlabs.docscribe.DoctorAction"
class="com.florexlabs.docscribe.actions.DoctorAction"
text="DocScribe Doctor"
description="Diagnose DocScribe plugin setup (Ruby SDK, gem, daemon)"/>

<group id="com.florexlabs.docscribe.DocScribeGroup"
text="DocScribe" popup="true"
icon="/com/florexlabs/docscribe/icons/docscribe.png">
Expand All @@ -120,6 +125,8 @@
<separator/>
<reference ref="com.florexlabs.docscribe.CheckWorkspaceAction"/>
<reference ref="com.florexlabs.docscribe.UpdateTypesAction"/>
<separator/>
<reference ref="com.florexlabs.docscribe.DoctorAction"/>
</group>
</actions>
</idea-plugin>
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading