From 33d20bd8dee71ee18a86e8d8b61c94396ebec9b1 Mon Sep 17 00:00:00 2001
From: "[NSObject init]" <49247588+unurgunite@users.noreply.github.com>
Date: Sun, 5 Jul 2026 15:59:51 +0300
Subject: [PATCH 1/9] [0.1.5] Make `com.intellij.modules.ruby` optional for
Marketplace verification (#27)
---
gradle.properties | 2 +-
src/main/resources/META-INF/plugin.xml | 25 ++-----------------
.../resources/META-INF/withRubyPlugin.xml | 25 +++++++++++++++++++
3 files changed, 28 insertions(+), 24 deletions(-)
create mode 100644 src/main/resources/META-INF/withRubyPlugin.xml
diff --git a/gradle.properties b/gradle.properties
index 0e1c125..6c8266b 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,6 +1,6 @@
pluginGroup = com.florexlabs
pluginName = docscribe-rubymine
-pluginVersion = 0.1.4
+pluginVersion = 0.1.5
pluginSinceBuild = 261
pluginUntilBuild = 261.*
diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml
index 7f92e0c..d24ef1a 100644
--- a/src/main/resources/META-INF/plugin.xml
+++ b/src/main/resources/META-INF/plugin.xml
@@ -45,11 +45,11 @@
]]>
- 0.1.4
+ 0.1.5
com.intellij.modules.platform
- com.intellij.modules.ruby
+ com.intellij.modules.ruby
@@ -59,33 +59,12 @@
-
-
-
-
-
-
-
-
- com.florexlabs.docscribe.annotator.DocscribeFixIntention
- DocScribe
-
-
- com.florexlabs.docscribe.annotator.DocscribeAggressiveFixIntention
- DocScribe
-
-
- com.florexlabs.docscribe.annotator.DocscribeCheckIntention
- DocScribe
-
diff --git a/src/main/resources/META-INF/withRubyPlugin.xml b/src/main/resources/META-INF/withRubyPlugin.xml
new file mode 100644
index 0000000..f262d15
--- /dev/null
+++ b/src/main/resources/META-INF/withRubyPlugin.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+ com.florexlabs.docscribe.annotator.DocscribeFixIntention
+ DocScribe
+
+
+ com.florexlabs.docscribe.annotator.DocscribeAggressiveFixIntention
+ DocScribe
+
+
+ com.florexlabs.docscribe.annotator.DocscribeCheckIntention
+ DocScribe
+
+
+
From 6db18b6763501e14e2f00afe2ec2129b90b904b3 Mon Sep 17 00:00:00 2001
From: "[NSObject init]" <49247588+unurgunite@users.noreply.github.com>
Date: Sun, 5 Jul 2026 19:52:44 +0300
Subject: [PATCH 2/9] [0.1.5] Graceful handling when docscribe gem not
installed (#29)
---
CHANGELOG.md | 25 ++++
build.gradle.kts | 1 +
.../docscribe/runner/DocscribeDaemon.kt | 130 +++++++++++++++++-
.../runner/DocscribeDaemonGemTest.kt | 48 +++++++
4 files changed, 202 insertions(+), 2 deletions(-)
create mode 100644 src/test/kotlin/com/florexlabs/docscribe/runner/DocscribeDaemonGemTest.kt
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e37ca35..897b38e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,30 @@
# Changelog
+## [0.1.5] — 2026-07-05
+
+### Added
+
+- **Graceful handling when docscribe gem not installed** — `DocscribeDaemon` now runs
+ `bundle exec docscribe --version` once on first use and caches the result. If the gem is missing, a
+ user-friendly notification (with "Open Gemfile" action) is shown once, and all operations return a
+ clear error: *"Add 'gem \"docscribe\"' to your Gemfile and run 'bundle install'"*.
+- **Tests for gem availability check** — 5 new tests in `DocscribeDaemonGemTest.kt` covering MISSING
+ status, error message format, all command types, and status caching.
+
+### Changed
+
+- **`com.intellij.modules.ruby` made optional** — changed from hard `` to
+ ``. Ruby-specific extensions
+ (`ExternalAnnotator`, `FoldingBuilder`, `DependencySupport`, `IntentionAction`) moved to a new
+ secondary descriptor. Plugin now installs on IntelliJ IDEA without Ruby plugin (limited
+ functionality), unblocking Marketplace publication.
+- **Version** bumped from `0.1.4` to `0.1.5`.
+
+### Build
+
+- **`intellijDependencies()`** — added to repository section in `build.gradle.kts` (required by
+ `instrumentTestCode` task).
+
## [0.1.4] — 2026-06-29
### Fixed
diff --git a/build.gradle.kts b/build.gradle.kts
index 6be165a..c3f3ae6 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -16,6 +16,7 @@ repositories {
mavenCentral()
intellijPlatform {
defaultRepositories()
+ intellijDependencies()
}
}
diff --git a/src/main/kotlin/com/florexlabs/docscribe/runner/DocscribeDaemon.kt b/src/main/kotlin/com/florexlabs/docscribe/runner/DocscribeDaemon.kt
index d436362..f34a06e 100644
--- a/src/main/kotlin/com/florexlabs/docscribe/runner/DocscribeDaemon.kt
+++ b/src/main/kotlin/com/florexlabs/docscribe/runner/DocscribeDaemon.kt
@@ -4,10 +4,15 @@ import com.google.gson.GsonBuilder
import com.intellij.notification.NotificationGroupManager
import com.intellij.notification.NotificationType
import com.intellij.openapi.Disposable
+import com.intellij.openapi.actionSystem.AnAction
+import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.components.Service
import com.intellij.openapi.diagnostic.Logger
+import com.intellij.openapi.fileEditor.FileEditorManager
import com.intellij.openapi.project.Project
import com.intellij.openapi.roots.ProjectRootManager
+import com.intellij.openapi.vfs.LocalFileSystem
+import org.jetbrains.annotations.VisibleForTesting
import java.io.File
import java.io.IOException
import java.net.StandardProtocolFamily
@@ -22,10 +27,18 @@ import kotlin.concurrent.Volatile
/**
* Project-level service managing a long-running docscribe server process over Unix domain socket RPC.
*
+ * ## Gem availability
+ * On first use, runs `bundle exec docscribe --version` to verify the gem is installed
+ * ([performGemCheck]). The result is cached in [docscribeStatus] for the session.
+ * If the gem is missing, a user notification with "Open Gemfile" action is shown once,
+ * and all subsequent calls return a descriptive error without retrying.
+ *
+ * ## Execution
* Uses JSON-RPC 2.0 over a Unix socket to communicate with a headless docscribe server.
- * Falls back to direct CLI execution ([DocscribeRunner.runDocscribe]) if the server is unavailable.
+ * Falls back to direct CLI execution ([DocscribeRunner.runDocscribe]) if the server is
+ * unavailable or the gem is known missing.
*
- * Supported RPC methods:
+ * ## RPC methods
* - `check` — dry-run diagnostics.
* - `fix` — with `"strategy": "safe"` or `"aggressive"`.
* - `ping` — health check.
@@ -47,6 +60,24 @@ class DocscribeDaemon(
@Volatile
private var alive = false
+ /**
+ * Whether the `docscribe` gem is available in the project.
+ *
+ * - [DocscribeStatus.UNCHECKED] — not yet verified (initial state).
+ * - [DocscribeStatus.AVAILABLE] — `bundle exec docscribe --version` succeeded.
+ * - [DocscribeStatus.MISSING] — gem not found; don't retry this session.
+ */
+ @Volatile
+ @VisibleForTesting
+ internal var docscribeStatus = DocscribeStatus.UNCHECKED
+
+ /** True once a "gem not found" notification has been shown this session. */
+ @Volatile
+ private var missingNotified = false
+
+ /** Tracks whether the `docscribe` gem is installed in the project. */
+ enum class DocscribeStatus { UNCHECKED, AVAILABLE, MISSING }
+
/**
* Internal handle holding the server process and its Unix socket path.
*
@@ -212,6 +243,14 @@ class DocscribeDaemon(
val existing = server
if (existing != null && alive) return existing
+ // One-time check: is the docscribe gem available?
+ if (docscribeStatus == DocscribeStatus.UNCHECKED) {
+ performGemCheck()
+ }
+ if (docscribeStatus == DocscribeStatus.MISSING) {
+ return null
+ }
+
val ruby = rubyCommand()
val gemRoot = if (ruby != null) DocscribeRunner.findProjectRoot(projectDir ?: project.basePath ?: "") else null
val proc = if (gemRoot != null) startServerProcess(ruby!!, gemRoot) else null
@@ -335,6 +374,81 @@ class DocscribeDaemon(
group.createNotification(message, NotificationType.ERROR).notify(project)
}
+ /**
+ * Check whether `bundle exec docscribe --version` succeeds.
+ *
+ * Caches the result in [docscribeStatus] so the check runs only once per session.
+ * Shows a user-friendly notification (with "Open Gemfile" action) on first failure.
+ */
+ @VisibleForTesting
+ internal fun performGemCheck() {
+ if (docscribeStatus != DocscribeStatus.UNCHECKED) return
+ docscribeStatus = DocscribeStatus.MISSING // pessimistic default
+
+ val gemRoot = DocscribeRunner.findProjectRoot(project.basePath ?: "")
+ if (gemRoot == null) {
+ if (!missingNotified) showMissingDocscribeNotification(project.basePath ?: "")
+ return
+ }
+
+ try {
+ val pb =
+ ProcessBuilder("bundle", "exec", "docscribe", "--version")
+ .directory(File(gemRoot))
+ pb.redirectErrorStream(true)
+ val proc = pb.start()
+ val exited = proc.waitFor(GEM_CHECK_TIMEOUT_SECONDS, TimeUnit.SECONDS)
+ if (!exited) {
+ proc.destroyForcibly()
+ log.warn("docscribe version check timed out after ${GEM_CHECK_TIMEOUT_SECONDS}s")
+ return
+ }
+ if (proc.exitValue() == 0) {
+ docscribeStatus = DocscribeStatus.AVAILABLE
+ log.info("docscribe gem detected (version check passed)")
+ } else {
+ val output = proc.inputStream.bufferedReader().readText()
+ log.warn("docscribe version check failed: exit ${proc.exitValue()}, output: $output")
+ }
+ } catch (e: IOException) {
+ log.warn("Failed to run docscribe version check", e)
+ }
+
+ if (docscribeStatus == DocscribeStatus.MISSING && !missingNotified) {
+ showMissingDocscribeNotification(gemRoot)
+ }
+ }
+
+ /**
+ * Show a one-time error notification that the docscribe gem is missing,
+ * with a quick-action to open the project Gemfile.
+ */
+ private fun showMissingDocscribeNotification(gemRoot: String) {
+ missingNotified = true
+ val message =
+ "docscribe gem not found in project Gemfile. " +
+ "Add 'gem \"docscribe\"' and run 'bundle install'."
+ val notification =
+ NotificationGroupManager
+ .getInstance()
+ .getNotificationGroup("DocScribe")
+ .createNotification(message, NotificationType.ERROR)
+ notification.addAction(
+ object : AnAction("Open Gemfile") {
+ override fun actionPerformed(e: AnActionEvent) {
+ val gemFile = File(gemRoot, "Gemfile")
+ if (gemFile.exists()) {
+ val vFile = LocalFileSystem.getInstance().refreshAndFindFileByIoFile(gemFile)
+ if (vFile != null) {
+ FileEditorManager.getInstance(project).openFile(vFile, true)
+ }
+ }
+ }
+ },
+ )
+ notification.notify(project)
+ }
+
/**
* Resolve the Ruby executable path, preferring the project SDK if configured.
*
@@ -449,6 +563,17 @@ class DocscribeDaemon(
projectDir: String?,
formatJson: Boolean,
): RunResult {
+ if (docscribeStatus == DocscribeStatus.MISSING) {
+ return RunResult(
+ success = false,
+ hasIssues = false,
+ exitCode = 2,
+ stdout = "",
+ stderr =
+ "docscribe gem is not installed. " +
+ "Add 'gem \"docscribe\"' to your Gemfile and run 'bundle install'.",
+ )
+ }
val strategy = strategyFromCommand(command)
val options =
RunOptions(
@@ -490,6 +615,7 @@ class DocscribeDaemon(
private const val OUTPUT_TRIM_LENGTH = 500
private const val RPC_BUFFER_SIZE = 65536
private const val RUBY_PATH_LOOKUP_TIMEOUT_SECONDS = 3L
+ private const val GEM_CHECK_TIMEOUT_SECONDS = 10L
private val sharedGson by lazy { GsonBuilder().create() }
/**
diff --git a/src/test/kotlin/com/florexlabs/docscribe/runner/DocscribeDaemonGemTest.kt b/src/test/kotlin/com/florexlabs/docscribe/runner/DocscribeDaemonGemTest.kt
new file mode 100644
index 0000000..febc296
--- /dev/null
+++ b/src/test/kotlin/com/florexlabs/docscribe/runner/DocscribeDaemonGemTest.kt
@@ -0,0 +1,48 @@
+package com.florexlabs.docscribe.runner
+
+import com.intellij.testFramework.fixtures.BasePlatformTestCase
+
+class DocscribeDaemonGemTest : BasePlatformTestCase() {
+ fun testExecuteReturnsDescriptiveErrorWhenDocscribeMissing() {
+ val daemon = DocscribeDaemon(project)
+ daemon.docscribeStatus = DocscribeDaemon.DocscribeStatus.MISSING
+ val result = daemon.execute("check", file = "test.rb", projectDir = "/tmp")
+ assertFalse(result.success)
+ assertEquals(2, result.exitCode)
+ assertTrue(result.stderr.contains("gem \"docscribe\""))
+ assertTrue(result.stderr.contains("bundle install"))
+ }
+
+ fun testFallbackDoesNotRunCliWhenDocscribeMissing() {
+ val daemon = DocscribeDaemon(project)
+ daemon.docscribeStatus = DocscribeDaemon.DocscribeStatus.MISSING
+ val result = daemon.execute("safe_fix", file = "test.rb", projectDir = "/tmp")
+ assertFalse(result.success)
+ assertEquals(2, result.exitCode)
+ assertTrue(result.stderr.contains("gem \"docscribe\""))
+ }
+
+ fun testExecuteReturnsSameErrorForAllCommands() {
+ val daemon = DocscribeDaemon(project)
+ daemon.docscribeStatus = DocscribeDaemon.DocscribeStatus.MISSING
+ for (cmd in listOf("check", "safe_fix", "aggressive_fix", "update_types")) {
+ val result = daemon.execute(cmd, file = "test.rb", projectDir = "/tmp")
+ assertFalse("$cmd should fail when gem missing", result.success)
+ assertTrue("$cmd should mention gem in stderr", result.stderr.contains("docscribe gem"))
+ }
+ }
+
+ fun testGemCheckSkipsWhenAlreadyChecked() {
+ val daemon = DocscribeDaemon(project)
+ // Set to AVAILABLE before calling performGemCheck — should skip the real process
+ daemon.docscribeStatus = DocscribeDaemon.DocscribeStatus.AVAILABLE
+ // Should return immediately without throwing
+ daemon.performGemCheck()
+ assertEquals(DocscribeDaemon.DocscribeStatus.AVAILABLE, daemon.docscribeStatus)
+ }
+
+ fun testDocscribeStatusDefaultsToUnchecked() {
+ val daemon = DocscribeDaemon(project)
+ assertEquals(DocscribeDaemon.DocscribeStatus.UNCHECKED, daemon.docscribeStatus)
+ }
+}
From 69d1f5ce8e49b74d21379c0819ee77305d73f9a8 Mon Sep 17 00:00:00 2001
From: "[NSObject init]" <49247588+unurgunite@users.noreply.github.com>
Date: Sun, 5 Jul 2026 20:31:04 +0300
Subject: [PATCH 3/9] [0.1.5] Fix annotation cache invalidation: compute
configHash from settings (#30)
---
CHANGELOG.md | 10 ++++
.../docscribe/annotator/DocscribeAnnotator.kt | 15 ++++-
.../annotator/DocscribeAnnotatorCache.kt | 42 +++++++++++++-
.../DocscribeSettingsChangeListener.kt | 7 ++-
.../DocscribeAnnotatorCacheSettingsTest.kt | 46 ++++++++++++++++
.../annotator/DocscribeAnnotatorCacheTest.kt | 55 +++++++++++++++++++
6 files changed, 168 insertions(+), 7 deletions(-)
create mode 100644 src/test/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotatorCacheSettingsTest.kt
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 897b38e..4c0f163 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,8 +18,18 @@
(`ExternalAnnotator`, `FoldingBuilder`, `DependencySupport`, `IntentionAction`) moved to a new
secondary descriptor. Plugin now installs on IntelliJ IDEA without Ruby plugin (limited
functionality), unblocking Marketplace publication.
+- **Annotation cache now respects settings** — `configHash` in `AnnotatorFileInfo` is now derived
+ from `DocscribeSettings.hideCommentsByDefault` instead of being hardcoded to `0`. Changing
+ settings automatically invalidates cached annotations. Cache also has a max size (1000 entries)
+ with LRU-like eviction.
- **Version** bumped from `0.1.4` to `0.1.5`.
+### Fixed
+
+- **Annotation cache never invalidated on settings change** — `DocscribeSettingsChangeListener`
+ now calls `DocscribeAnnotatorCache.clear()` when settings are saved, in addition to refreshing
+ code folding.
+
### Build
- **`intellijDependencies()`** — added to repository section in `build.gradle.kts` (required by
diff --git a/src/main/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotator.kt b/src/main/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotator.kt
index 5f078cc..e02c729 100644
--- a/src/main/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotator.kt
+++ b/src/main/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotator.kt
@@ -5,6 +5,7 @@ import com.florexlabs.docscribe.runner.DocscribeOutput
import com.florexlabs.docscribe.runner.DocscribeOutputParser
import com.florexlabs.docscribe.runner.DocscribeStrategy
import com.florexlabs.docscribe.runner.RunOptions
+import com.florexlabs.docscribe.settings.DocscribeSettings
import com.intellij.lang.annotation.AnnotationHolder
import com.intellij.lang.annotation.ExternalAnnotator
import com.intellij.lang.annotation.HighlightSeverity
@@ -14,9 +15,14 @@ import com.intellij.openapi.project.Project
import com.intellij.openapi.util.TextRange
import com.intellij.psi.PsiDocumentManager
import com.intellij.psi.PsiFile
+import java.util.Objects
/**
* Information collected by the annotator before running the background check.
+ *
+ * @property configHash Hash of [DocscribeSettings] used as part of the cache key.
+ * When settings change, the hash changes, causing cache misses and forcing re-annotation
+ * with the new settings.
*/
data class AnnotatorFileInfo(
val filePath: String,
@@ -31,6 +37,11 @@ data class AnnotatorFileInfo(
*
* Triggers automatically when a Ruby file is opened or saved. Uses JSON output for structured parsing.
* Skips unsaved documents (docscribe reads from disk) and caches results by file modification stamp.
+ *
+ * Cache invalidation: the [AnnotatorFileInfo.configHash] is derived from [DocscribeSettings],
+ * so changing settings (e.g. `hideCommentsByDefault`) automatically invalidates cached annotations
+ * by producing a different configHash. The DocscribeSettingsChangeListener also clears the cache
+ * explicitly on settings change.
*/
class DocscribeAnnotator : ExternalAnnotator() {
/**
@@ -55,7 +66,7 @@ class DocscribeAnnotator : ExternalAnnotator
// Skip unsaved documents — docscribe reads from disk, not from editor buffer
if (FileDocumentManager.getInstance().isDocumentUnsaved(editor.document)) return null
- val configHash = 0
+ val configHash = Objects.hash(DocscribeSettings.getInstance().hideCommentsByDefault)
return AnnotatorFileInfo(
filePath = vFile.path,
@@ -79,7 +90,7 @@ class DocscribeAnnotator : ExternalAnnotator
val vFile = file.virtualFile ?: return null
val projectDir = file.project.basePath ?: return null
- val configHash = 0
+ val configHash = Objects.hash(DocscribeSettings.getInstance().hideCommentsByDefault)
return AnnotatorFileInfo(
filePath = vFile.path,
diff --git a/src/main/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotatorCache.kt b/src/main/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotatorCache.kt
index f89c1bb..2252468 100644
--- a/src/main/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotatorCache.kt
+++ b/src/main/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotatorCache.kt
@@ -10,6 +10,8 @@ import java.util.concurrent.ConcurrentHashMap
*
* Keyed by (projectPath, filePath, configHash) and validated by file modification stamp.
* Prevents re-running docscribe on unchanged files between saves.
+ *
+ * Maximum cache size: [MAX_CACHE_SIZE]. When exceeded, the oldest entries are evicted.
*/
@Service
class DocscribeAnnotatorCache {
@@ -25,10 +27,12 @@ class DocscribeAnnotatorCache {
)
private val cache = ConcurrentHashMap()
+ private val insertionOrder = mutableListOf()
/**
* Returns cached result if the file has not been modified since it was cached.
*/
+ @Synchronized
fun get(
projectPath: String,
filePath: String,
@@ -41,8 +45,9 @@ class DocscribeAnnotatorCache {
}
/**
- * Stores a result in the cache.
+ * Stores a result in the cache. Evicts oldest entries if cache exceeds [MAX_CACHE_SIZE].
*/
+ @Synchronized
fun put(
projectPath: String,
filePath: String,
@@ -50,19 +55,50 @@ class DocscribeAnnotatorCache {
configHash: Int,
result: DocscribeOutput?,
) {
+ evictIfNeeded()
val key = Key(projectPath, filePath, configHash)
- cache[key] = Entry(fileStamp, result)
+ if (cache.put(key, Entry(fileStamp, result)) == null) {
+ insertionOrder.add(key)
+ }
}
/**
* Invalidates all cache entries for a given file path.
*/
- @Suppress("unused")
fun invalidate(filePath: String) {
cache.keys.removeIf { key -> key.filePath == filePath }
+ insertionOrder.removeIf { it.filePath == filePath }
+ }
+
+ /**
+ * Clears the entire cache.
+ */
+ fun clear() {
+ cache.clear()
+ insertionOrder.clear()
+ }
+
+ /**
+ * Returns the current number of cached entries (for testing and monitoring).
+ */
+ fun size(): Int = cache.size
+
+ /**
+ * If the cache has exceeded [MAX_CACHE_SIZE], removes the oldest quarter of entries.
+ */
+ private fun evictIfNeeded() {
+ while (cache.size >= MAX_CACHE_SIZE) {
+ val evictCount = (MAX_CACHE_SIZE / 4).coerceAtLeast(1)
+ val toEvict = insertionOrder.take(evictCount)
+ insertionOrder.removeAll(toEvict)
+ toEvict.forEach { cache.remove(it) }
+ }
}
companion object {
+ /** Maximum number of annotated files to cache. */
+ const val MAX_CACHE_SIZE = 1000
+
/**
* Get the application-level [DocscribeAnnotatorCache] singleton.
*/
diff --git a/src/main/kotlin/com/florexlabs/docscribe/settings/DocscribeSettingsChangeListener.kt b/src/main/kotlin/com/florexlabs/docscribe/settings/DocscribeSettingsChangeListener.kt
index d3df2b5..771cf7e 100644
--- a/src/main/kotlin/com/florexlabs/docscribe/settings/DocscribeSettingsChangeListener.kt
+++ b/src/main/kotlin/com/florexlabs/docscribe/settings/DocscribeSettingsChangeListener.kt
@@ -1,5 +1,6 @@
package com.florexlabs.docscribe.settings
+import com.florexlabs.docscribe.annotator.DocscribeAnnotatorCache
import com.intellij.codeInsight.folding.CodeFoldingManager
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.components.Service
@@ -45,11 +46,13 @@ internal class DocscribeSettingsChangeListenerImpl : DocscribeSettingsChangeList
}
/**
- * Refresh code folding in all open editors when settings change.
+ * Clear annotation cache and refresh code folding in all open editors when settings change.
*
- * Iterates all open projects and their text editors, triggering an async folding region update.
+ * Cache invalidation ensures annotations are regenerated with the new settings.
+ * Folding refresh updates YARD comment collapse state.
*/
override fun settingsChanged() {
+ DocscribeAnnotatorCache.getInstance().clear()
for (project in ProjectManager.getInstance().openProjects) {
if (project.isDisposed) continue
val fileEditorManager = FileEditorManager.getInstance(project)
diff --git a/src/test/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotatorCacheSettingsTest.kt b/src/test/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotatorCacheSettingsTest.kt
new file mode 100644
index 0000000..0030d38
--- /dev/null
+++ b/src/test/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotatorCacheSettingsTest.kt
@@ -0,0 +1,46 @@
+package com.florexlabs.docscribe.annotator
+
+import com.florexlabs.docscribe.settings.DocscribeSettings
+import com.intellij.testFramework.fixtures.BasePlatformTestCase
+import java.util.Objects
+
+class DocscribeAnnotatorCacheSettingsTest : BasePlatformTestCase() {
+ private var savedHideCommentsByDefault = false
+
+ override fun setUp() {
+ super.setUp()
+ savedHideCommentsByDefault = DocscribeSettings.getInstance().hideCommentsByDefault
+ }
+
+ override fun tearDown() {
+ DocscribeSettings.getInstance().hideCommentsByDefault = savedHideCommentsByDefault
+ super.tearDown()
+ }
+
+ fun testConfigHashChangesWhenHideCommentsByDefaultChanges() {
+ val settings = DocscribeSettings.getInstance()
+ val hashBefore = Objects.hash(settings.hideCommentsByDefault)
+
+ settings.hideCommentsByDefault = !savedHideCommentsByDefault
+ val hashAfter = Objects.hash(settings.hideCommentsByDefault)
+
+ assertTrue(
+ "configHash should change when hideCommentsByDefault toggles: $hashBefore -> $hashAfter",
+ hashBefore != hashAfter,
+ )
+ }
+
+ fun testSameSettingsProducesSameConfigHash() {
+ val hash1 = Objects.hash(DocscribeSettings.getInstance().hideCommentsByDefault)
+ val hash2 = Objects.hash(DocscribeSettings.getInstance().hideCommentsByDefault)
+ assertEquals("hash should be stable for same settings", hash1, hash2)
+ }
+
+ fun testAnnotatorCollectInformationUsesNonZeroConfigHash() {
+ val file = myFixture.configureByText("test.rb", "class Foo\nend")
+ val info = DocscribeAnnotator().collectInformation(file)
+ assertNotNull("should collect info for .rb file", info)
+ val expectedHash = Objects.hash(DocscribeSettings.getInstance().hideCommentsByDefault)
+ assertEquals("configHash should match settings hash", expectedHash, info!!.configHash)
+ }
+}
diff --git a/src/test/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotatorCacheTest.kt b/src/test/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotatorCacheTest.kt
index 6d4ec11..1653456 100644
--- a/src/test/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotatorCacheTest.kt
+++ b/src/test/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotatorCacheTest.kt
@@ -3,6 +3,7 @@ package com.florexlabs.docscribe.annotator
import com.florexlabs.docscribe.runner.DocscribeOutput
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
import org.junit.Test
class DocscribeAnnotatorCacheTest {
@@ -52,4 +53,58 @@ class DocscribeAnnotatorCacheTest {
assertNull(cache.get("/project", "/file.rb", 100L, 1))
assertEquals(result, cache.get("/project", "/other.rb", 100L, 0))
}
+
+ @Test
+ fun clearRemovesAllEntries() {
+ cache.put("/project", "/a.rb", 100L, 0, result)
+ cache.put("/project", "/b.rb", 100L, 0, result)
+ cache.clear()
+ assertEquals(0, cache.size())
+ assertNull(cache.get("/project", "/a.rb", 100L, 0))
+ }
+
+ @Test
+ fun sizeReturnsCorrectCount() {
+ assertEquals(0, cache.size())
+ cache.put("/project", "/a.rb", 100L, 0, result)
+ assertEquals(1, cache.size())
+ cache.put("/project", "/b.rb", 100L, 0, result)
+ assertEquals(2, cache.size())
+ }
+
+ @Test
+ fun sizeDecreasesAfterInvalidate() {
+ cache.put("/project", "/a.rb", 100L, 0, result)
+ cache.put("/project", "/b.rb", 100L, 0, result)
+ cache.invalidate("/a.rb")
+ assertEquals(1, cache.size())
+ }
+
+ @Test
+ fun evictionRemovesOldestWhenOverMaxCacheSize() {
+ // Fill cache to max, then add one more
+ val max = DocscribeAnnotatorCache.MAX_CACHE_SIZE
+ for (i in 0 until max) {
+ cache.put("/project", "/file$i.rb", 100L, 0, result)
+ }
+ assertEquals(max, cache.size())
+
+ // Adding one more triggers eviction of oldest quarter
+ cache.put("/project", "/overflow.rb", 100L, 0, result)
+
+ // After eviction, size should be max - (max/4) + 1 = 751 (since max=1000, max/4=250)
+ val expectedSize = max - (max / 4) + 1
+ assertTrue("cache size $expectedSize after eviction", cache.size() <= max)
+ assertTrue("cache size > 0 after eviction", cache.size() > 0)
+ }
+
+ @Test
+ fun configHashSeparatesCacheEntries() {
+ cache.put("/project", "/file.rb", 100L, 42, result)
+ cache.put("/project", "/file.rb", 100L, 99, result)
+ assertEquals(2, cache.size())
+ assertEquals(result, cache.get("/project", "/file.rb", 100L, 42))
+ assertEquals(result, cache.get("/project", "/file.rb", 100L, 99))
+ assertNull(cache.get("/project", "/file.rb", 100L, 0))
+ }
}
From 792826e8047419a49f9cf111189eb8484cface68 Mon Sep 17 00:00:00 2001
From: "[NSObject init]" <49247588+unurgunite@users.noreply.github.com>
Date: Sun, 5 Jul 2026 20:58:11 +0300
Subject: [PATCH 4/9] [0.1.5] Add `Rakefile` (no extension) support to
annotator and actions
---
CHANGELOG.md | 3 +++
.../docscribe/actions/AggressiveFixAction.kt | 3 ++-
.../docscribe/actions/CheckFileAction.kt | 3 ++-
.../florexlabs/docscribe/actions/SafeFixAction.kt | 3 ++-
.../annotator/DocscribeAggressiveFixIntention.kt | 2 +-
.../docscribe/annotator/DocscribeAnnotator.kt | 4 ++--
.../docscribe/annotator/DocscribeCheckIntention.kt | 2 +-
.../docscribe/annotator/DocscribeFixIntention.kt | 2 +-
.../docscribe/actions/AggressiveFixActionTest.kt | 14 ++++++++++++++
.../docscribe/actions/CheckFileActionTest.kt | 14 ++++++++++++++
.../docscribe/actions/SafeFixActionTest.kt | 14 ++++++++++++++
.../docscribe/annotator/DocscribeAnnotatorTest.kt | 6 ++++++
12 files changed, 62 insertions(+), 8 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4c0f163..5f7aa32 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,9 @@
### Added
+- **Rakefile support (no extension)** — all annotators, actions, and intention actions now accept
+ a file named `Rakefile` (without `.rb` or `.rake` extension) in addition to `.rb` and `.rake`
+ files. This ensures the plugin works on standard Rakefiles that have no file extension.
- **Graceful handling when docscribe gem not installed** — `DocscribeDaemon` now runs
`bundle exec docscribe --version` once on first use and caches the result. If the gem is missing, a
user-friendly notification (with "Open Gemfile" action) is shown once, and all operations return a
diff --git a/src/main/kotlin/com/florexlabs/docscribe/actions/AggressiveFixAction.kt b/src/main/kotlin/com/florexlabs/docscribe/actions/AggressiveFixAction.kt
index 5930ccd..d8a0f2c 100644
--- a/src/main/kotlin/com/florexlabs/docscribe/actions/AggressiveFixAction.kt
+++ b/src/main/kotlin/com/florexlabs/docscribe/actions/AggressiveFixAction.kt
@@ -72,7 +72,8 @@ class AggressiveFixAction : AnAction() {
*/
override fun update(e: AnActionEvent) {
val file = e.getData(CommonDataKeys.VIRTUAL_FILE)
- e.presentation.isEnabledAndVisible = file != null && (file.name.endsWith(".rb") || file.name.endsWith(".rake"))
+ e.presentation.isEnabledAndVisible =
+ file != null && (file.name.endsWith(".rb") || file.name.endsWith(".rake") || file.name == "Rakefile")
}
/**
diff --git a/src/main/kotlin/com/florexlabs/docscribe/actions/CheckFileAction.kt b/src/main/kotlin/com/florexlabs/docscribe/actions/CheckFileAction.kt
index 1b1c1ac..a412396 100644
--- a/src/main/kotlin/com/florexlabs/docscribe/actions/CheckFileAction.kt
+++ b/src/main/kotlin/com/florexlabs/docscribe/actions/CheckFileAction.kt
@@ -70,7 +70,8 @@ class CheckFileAction : AnAction() {
*/
override fun update(e: AnActionEvent) {
val file = e.getData(CommonDataKeys.VIRTUAL_FILE)
- e.presentation.isEnabledAndVisible = file != null && (file.name.endsWith(".rb") || file.name.endsWith(".rake"))
+ e.presentation.isEnabledAndVisible =
+ file != null && (file.name.endsWith(".rb") || file.name.endsWith(".rake") || file.name == "Rakefile")
}
/**
diff --git a/src/main/kotlin/com/florexlabs/docscribe/actions/SafeFixAction.kt b/src/main/kotlin/com/florexlabs/docscribe/actions/SafeFixAction.kt
index 5801ee0..090476d 100644
--- a/src/main/kotlin/com/florexlabs/docscribe/actions/SafeFixAction.kt
+++ b/src/main/kotlin/com/florexlabs/docscribe/actions/SafeFixAction.kt
@@ -70,7 +70,8 @@ class SafeFixAction : AnAction() {
*/
override fun update(e: AnActionEvent) {
val file = e.getData(CommonDataKeys.VIRTUAL_FILE)
- e.presentation.isEnabledAndVisible = file != null && (file.name.endsWith(".rb") || file.name.endsWith(".rake"))
+ e.presentation.isEnabledAndVisible =
+ file != null && (file.name.endsWith(".rb") || file.name.endsWith(".rake") || file.name == "Rakefile")
}
/**
diff --git a/src/main/kotlin/com/florexlabs/docscribe/annotator/DocscribeAggressiveFixIntention.kt b/src/main/kotlin/com/florexlabs/docscribe/annotator/DocscribeAggressiveFixIntention.kt
index 352f8e5..d823b3c 100644
--- a/src/main/kotlin/com/florexlabs/docscribe/annotator/DocscribeAggressiveFixIntention.kt
+++ b/src/main/kotlin/com/florexlabs/docscribe/annotator/DocscribeAggressiveFixIntention.kt
@@ -38,7 +38,7 @@ class DocscribeAggressiveFixIntention : IntentionAction {
project: Project,
editor: Editor?,
file: PsiFile?,
- ): Boolean = file != null && (file.name.endsWith(".rb") || file.name.endsWith(".rake"))
+ ): Boolean = file != null && (file.name.endsWith(".rb") || file.name.endsWith(".rake") || file.name == "Rakefile")
/**
* Run docscribe aggressive fix in a background task, then refresh the file on success.
diff --git a/src/main/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotator.kt b/src/main/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotator.kt
index e02c729..66ee0f8 100644
--- a/src/main/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotator.kt
+++ b/src/main/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotator.kt
@@ -59,7 +59,7 @@ class DocscribeAnnotator : ExternalAnnotator
editor: Editor,
hasErrors: Boolean,
): AnnotatorFileInfo? {
- if (!file.name.endsWith(".rb") && !file.name.endsWith(".rake")) return null
+ if (!file.name.endsWith(".rb") && !file.name.endsWith(".rake") && file.name != "Rakefile") return null
val vFile = file.virtualFile ?: return null
val projectDir = file.project.basePath ?: return null
@@ -86,7 +86,7 @@ class DocscribeAnnotator : ExternalAnnotator
* @return [AnnotatorFileInfo] if the file should be checked, or `null` to skip.
*/
override fun collectInformation(file: PsiFile): AnnotatorFileInfo? {
- if (!file.name.endsWith(".rb") && !file.name.endsWith(".rake")) return null
+ if (!file.name.endsWith(".rb") && !file.name.endsWith(".rake") && file.name != "Rakefile") return null
val vFile = file.virtualFile ?: return null
val projectDir = file.project.basePath ?: return null
diff --git a/src/main/kotlin/com/florexlabs/docscribe/annotator/DocscribeCheckIntention.kt b/src/main/kotlin/com/florexlabs/docscribe/annotator/DocscribeCheckIntention.kt
index a7945d1..6d9324f 100644
--- a/src/main/kotlin/com/florexlabs/docscribe/annotator/DocscribeCheckIntention.kt
+++ b/src/main/kotlin/com/florexlabs/docscribe/annotator/DocscribeCheckIntention.kt
@@ -40,7 +40,7 @@ class DocscribeCheckIntention : IntentionAction {
project: Project,
editor: Editor?,
file: PsiFile?,
- ): Boolean = file != null && (file.name.endsWith(".rb") || file.name.endsWith(".rake"))
+ ): Boolean = file != null && (file.name.endsWith(".rb") || file.name.endsWith(".rake") || file.name == "Rakefile")
/**
* Run docscribe check in a background task and show a notification on completion.
diff --git a/src/main/kotlin/com/florexlabs/docscribe/annotator/DocscribeFixIntention.kt b/src/main/kotlin/com/florexlabs/docscribe/annotator/DocscribeFixIntention.kt
index 489a515..5586b58 100644
--- a/src/main/kotlin/com/florexlabs/docscribe/annotator/DocscribeFixIntention.kt
+++ b/src/main/kotlin/com/florexlabs/docscribe/annotator/DocscribeFixIntention.kt
@@ -37,7 +37,7 @@ class DocscribeFixIntention : IntentionAction {
project: Project,
editor: Editor?,
file: PsiFile?,
- ): Boolean = file != null && (file.name.endsWith(".rb") || file.name.endsWith(".rake"))
+ ): Boolean = file != null && (file.name.endsWith(".rb") || file.name.endsWith(".rake") || file.name == "Rakefile")
/**
* Save the editor, find the project root, and run docscribe safe fix in a background task.
diff --git a/src/test/kotlin/com/florexlabs/docscribe/actions/AggressiveFixActionTest.kt b/src/test/kotlin/com/florexlabs/docscribe/actions/AggressiveFixActionTest.kt
index be1ebeb..8559ae4 100644
--- a/src/test/kotlin/com/florexlabs/docscribe/actions/AggressiveFixActionTest.kt
+++ b/src/test/kotlin/com/florexlabs/docscribe/actions/AggressiveFixActionTest.kt
@@ -25,6 +25,20 @@ class AggressiveFixActionTest : BasePlatformTestCase() {
assertTrue(presentation.isEnabledAndVisible)
}
+ fun testAggressiveFixActionIsEnabledForRakefile() {
+ val file = myFixture.configureByText("Rakefile", "task :test do\nend").virtualFile
+ val dataContext =
+ SimpleDataContext
+ .builder()
+ .add(CommonDataKeys.VIRTUAL_FILE, file)
+ .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 testAggressiveFixActionIsDisabledForNonRubyFile() {
val file = myFixture.configureByText("foo.txt", "plain text").virtualFile
val dataContext =
diff --git a/src/test/kotlin/com/florexlabs/docscribe/actions/CheckFileActionTest.kt b/src/test/kotlin/com/florexlabs/docscribe/actions/CheckFileActionTest.kt
index 768511e..2f0f449 100644
--- a/src/test/kotlin/com/florexlabs/docscribe/actions/CheckFileActionTest.kt
+++ b/src/test/kotlin/com/florexlabs/docscribe/actions/CheckFileActionTest.kt
@@ -25,6 +25,20 @@ class CheckFileActionTest : BasePlatformTestCase() {
assertTrue(presentation.isEnabledAndVisible)
}
+ fun testActionIsEnabledForRakefile() {
+ val file = myFixture.configureByText("Rakefile", "task :test do\nend").virtualFile
+ val dataContext =
+ SimpleDataContext
+ .builder()
+ .add(CommonDataKeys.VIRTUAL_FILE, file)
+ .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 testActionIsDisabledForNonRubyFile() {
val file = myFixture.configureByText("foo.txt", "plain text").virtualFile
val dataContext =
diff --git a/src/test/kotlin/com/florexlabs/docscribe/actions/SafeFixActionTest.kt b/src/test/kotlin/com/florexlabs/docscribe/actions/SafeFixActionTest.kt
index cce5b51..b55e398 100644
--- a/src/test/kotlin/com/florexlabs/docscribe/actions/SafeFixActionTest.kt
+++ b/src/test/kotlin/com/florexlabs/docscribe/actions/SafeFixActionTest.kt
@@ -25,6 +25,20 @@ class SafeFixActionTest : BasePlatformTestCase() {
assertTrue(presentation.isEnabledAndVisible)
}
+ fun testSafeFixActionIsEnabledForRakefile() {
+ val file = myFixture.configureByText("Rakefile", "task :test do\nend").virtualFile
+ val dataContext =
+ SimpleDataContext
+ .builder()
+ .add(CommonDataKeys.VIRTUAL_FILE, file)
+ .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 testSafeFixActionIsDisabledForNonRubyFile() {
val file = myFixture.configureByText("foo.txt", "plain text").virtualFile
val dataContext =
diff --git a/src/test/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotatorTest.kt b/src/test/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotatorTest.kt
index 17b30a1..d774da5 100644
--- a/src/test/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotatorTest.kt
+++ b/src/test/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotatorTest.kt
@@ -23,6 +23,12 @@ class DocscribeAnnotatorTest : BasePlatformTestCase() {
assertNotNull(info)
}
+ fun testAnnotatorReturnsInfoForPlainRakefile() {
+ val file = myFixture.configureByText("Rakefile", "task :test do\nend")
+ val info = DocscribeAnnotator().collectInformation(file)
+ assertNotNull(info)
+ }
+
fun testNoEditorOverloadReturnsInfo() {
val file = myFixture.configureByText("test.rb", "class Foo\nend")
val info = DocscribeAnnotator().collectInformation(file)
From f0bdc1fcccf395f712db4d5099794d972df0f273 Mon Sep 17 00:00:00 2001
From: "[NSObject init]" <49247588+unurgunite@users.noreply.github.com>
Date: Sun, 5 Jul 2026 21:14:12 +0300
Subject: [PATCH 5/9] [0.1.5] Use project Ruby SDK for all `bundle exec
docscribe` commands
---
CHANGELOG.md | 5 +++
.../docscribe/runner/DocscribeDaemon.kt | 33 +++++++++++++------
.../docscribe/runner/DocscribeRunner.kt | 5 ++-
3 files changed, 32 insertions(+), 11 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5f7aa32..9fb6a96 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,11 @@
### Changed
+- **All commands now use project Ruby SDK** — `bundle exec docscribe` (via `DocscribeRunner`
+ and `DocscribeDaemon.fallback()`) and `bundle exec docscribe --version` (via
+ `DocscribeDaemon.performGemCheck()`) now prepend the Ruby SDK's `bin/` directory to `PATH`
+ and set `BUNDLE_GEMFILE`. This ensures the SDK's Ruby and Bundler are used instead of system
+ defaults. Extracted shared `buildSdkEnvironment()` helper, also reused in server startup.
- **`com.intellij.modules.ruby` made optional** — changed from hard `` to
``. Ruby-specific extensions
(`ExternalAnnotator`, `FoldingBuilder`, `DependencySupport`, `IntentionAction`) moved to a new
diff --git a/src/main/kotlin/com/florexlabs/docscribe/runner/DocscribeDaemon.kt b/src/main/kotlin/com/florexlabs/docscribe/runner/DocscribeDaemon.kt
index f34a06e..aa8a21b 100644
--- a/src/main/kotlin/com/florexlabs/docscribe/runner/DocscribeDaemon.kt
+++ b/src/main/kotlin/com/florexlabs/docscribe/runner/DocscribeDaemon.kt
@@ -307,15 +307,7 @@ class DocscribeDaemon(
val pb = ProcessBuilder(ruby, "-e", script).directory(File(gemRoot))
val env = pb.environment()
- val sdk = ProjectRootManager.getInstance(project).projectSdk
- if (sdk?.homePath != null) {
- val sdkBin = File(ruby).parentFile?.absolutePath
- if (sdkBin != null) {
- val currentPath = env["PATH"] ?: ""
- env["PATH"] = "$sdkBin${File.pathSeparator}$currentPath"
- }
- env["BUNDLE_GEMFILE"] = File(gemRoot, "Gemfile").absolutePath
- }
+ env.putAll(buildSdkEnvironment())
val localGemPath = System.getProperty("docscribe.local.gem.path")
if (localGemPath != null) {
@@ -395,6 +387,7 @@ class DocscribeDaemon(
val pb =
ProcessBuilder("bundle", "exec", "docscribe", "--version")
.directory(File(gemRoot))
+ pb.environment().putAll(buildSdkEnvironment())
pb.redirectErrorStream(true)
val proc = pb.start()
val exited = proc.waitFor(GEM_CHECK_TIMEOUT_SECONDS, TimeUnit.SECONDS)
@@ -504,6 +497,24 @@ class DocscribeDaemon(
return if (path != null && path.isNotBlank() && File(path).canExecute()) path else null
}
+ /**
+ * Build environment variables that point `bundle` and `ruby` to the project's Ruby SDK.
+ *
+ * Prepends the SDK's `bin/` directory to `PATH` and sets `BUNDLE_GEMFILE`.
+ * Returns an empty map when no SDK is configured or the SDK binary is not found.
+ */
+ private fun buildSdkEnvironment(): Map {
+ val ruby = rubyCommand() ?: return emptyMap()
+ val sdkBin = File(ruby).parentFile?.absolutePath ?: return emptyMap()
+ val currentPath = System.getenv("PATH") ?: ""
+ val env = mutableMapOf("PATH" to "$sdkBin${File.pathSeparator}$currentPath")
+ val gemRoot = DocscribeRunner.findProjectRoot(project.basePath ?: "")
+ if (gemRoot != null) {
+ env["BUNDLE_GEMFILE"] = File(gemRoot, "Gemfile").absolutePath
+ }
+ return env
+ }
+
/**
* Perform a single JSON-RPC 2.0 call over a Unix domain socket.
*
@@ -582,7 +593,9 @@ class DocscribeDaemon(
strategy = strategy,
formatJson = formatJson,
)
- return DocscribeRunner.runDocscribe(options, DefaultCommandExecutor())
+ val sdkEnv = buildSdkEnvironment()
+ val executor = if (sdkEnv.isEmpty()) DefaultCommandExecutor() else DefaultCommandExecutor(sdkEnv)
+ return DocscribeRunner.runDocscribe(options, executor)
}
/**
diff --git a/src/main/kotlin/com/florexlabs/docscribe/runner/DocscribeRunner.kt b/src/main/kotlin/com/florexlabs/docscribe/runner/DocscribeRunner.kt
index b756cd0..3a65291 100644
--- a/src/main/kotlin/com/florexlabs/docscribe/runner/DocscribeRunner.kt
+++ b/src/main/kotlin/com/florexlabs/docscribe/runner/DocscribeRunner.kt
@@ -83,7 +83,9 @@ interface CommandExecutor {
*
* Applies a 120-second timeout. Exit code 2 is treated as a failure (indistinguishable from timeout).
*/
-class DefaultCommandExecutor : CommandExecutor {
+class DefaultCommandExecutor(
+ private val environment: Map = emptyMap(),
+) : CommandExecutor {
override fun execute(
cmd: String,
args: List,
@@ -93,6 +95,7 @@ class DefaultCommandExecutor : CommandExecutor {
GeneralCommandLine(cmd)
.withParameters(args)
.withWorkDirectory(cwd)
+ .withEnvironment(environment)
val handler = CapturingProcessHandler(commandLine)
val output =
try {
From e11bc2381d9b3be4112d050a9dcc1ef511cf8522 Mon Sep 17 00:00:00 2001
From: "[NSObject init]" <49247588+unurgunite@users.noreply.github.com>
Date: Sun, 5 Jul 2026 21:37:20 +0300
Subject: [PATCH 6/9] [0.1.5] Limit concurrency: discard stale annotation
results per file; add SDK env tests (#33)
---
CHANGELOG.md | 5 ++
.../docscribe/annotator/DocscribeAnnotator.kt | 46 ++++++++++++++++---
.../docscribe/runner/DocscribeRunner.kt | 4 ++
.../annotator/DocscribeAnnotatorTest.kt | 39 ++++++++++++++++
.../docscribe/runner/DocscribeRunnerTest.kt | 20 ++++++++
5 files changed, 107 insertions(+), 7 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9fb6a96..cb97835 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,11 @@
### Changed
+- **Rapid annotations now discard stale results** — when the IDE triggers multiple checks for the
+ same file in quick succession (e.g. series of saves), each file has a generation counter. If a
+ newer check starts while an older one is still running, the older result is discarded on
+ completion. Only the last check's annotations are applied to the editor (see
+ `DocscribeAnnotator.fileGeneration`).
- **All commands now use project Ruby SDK** — `bundle exec docscribe` (via `DocscribeRunner`
and `DocscribeDaemon.fallback()`) and `bundle exec docscribe --version` (via
`DocscribeDaemon.performGemCheck()`) now prepend the Ruby SDK's `bin/` directory to `PATH`
diff --git a/src/main/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotator.kt b/src/main/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotator.kt
index 66ee0f8..33fffc8 100644
--- a/src/main/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotator.kt
+++ b/src/main/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotator.kt
@@ -15,7 +15,9 @@ import com.intellij.openapi.project.Project
import com.intellij.openapi.util.TextRange
import com.intellij.psi.PsiDocumentManager
import com.intellij.psi.PsiFile
+import org.jetbrains.annotations.VisibleForTesting
import java.util.Objects
+import java.util.concurrent.ConcurrentHashMap
/**
* Information collected by the annotator before running the background check.
@@ -38,10 +40,16 @@ data class AnnotatorFileInfo(
* Triggers automatically when a Ruby file is opened or saved. Uses JSON output for structured parsing.
* Skips unsaved documents (docscribe reads from disk) and caches results by file modification stamp.
*
- * Cache invalidation: the [AnnotatorFileInfo.configHash] is derived from [DocscribeSettings],
- * so changing settings (e.g. `hideCommentsByDefault`) automatically invalidates cached annotations
- * by producing a different configHash. The DocscribeSettingsChangeListener also clears the cache
- * explicitly on settings change.
+ * ## Concurrency
+ * When the IDE triggers multiple rapid annotations for the same file (e.g. during a series of quick saves),
+ * only the last check result is applied. Each file has a generation counter — if a newer check starts
+ * while an older one is still running, the older result is discarded on completion.
+ *
+ * ## Cache invalidation
+ * The [AnnotatorFileInfo.configHash] is derived from [DocscribeSettings], so changing settings
+ * (e.g. `hideCommentsByDefault`) automatically invalidates cached annotations by producing a
+ * different configHash. The DocscribeSettingsChangeListener also clears the cache explicitly
+ * on settings change.
*/
class DocscribeAnnotator : ExternalAnnotator() {
/**
@@ -104,12 +112,20 @@ class DocscribeAnnotator : ExternalAnnotator
/**
* Run docscribe check on the collected file, using the cache if the file is unchanged.
*
+ * Each file has a generation counter. When a new check starts for a file, the counter is
+ * incremented. If an older check completes and finds its generation is stale (a newer check
+ * already started), the result is discarded. This ensures that during rapid saves only the
+ * last check result is applied.
+ *
* @param info The file information collected by [collectInformation].
* @return Parsed [DocscribeOutput], or `null` if the file has no issues or the check failed.
*/
override fun doAnnotate(info: AnnotatorFileInfo): DocscribeOutput? {
+ val filePath = info.filePath
+ val generation = fileGeneration.merge(filePath, 1L) { _, old -> old + 1 }
+
val cache = DocscribeAnnotatorCache.getInstance()
- val cached = cache.get(info.projectDir, info.filePath, info.fileStamp, info.configHash)
+ val cached = cache.get(info.projectDir, filePath, info.fileStamp, info.configHash)
if (cached != null) {
return if (cached.files.isEmpty()) null else cached
}
@@ -117,11 +133,15 @@ class DocscribeAnnotator : ExternalAnnotator
val options =
RunOptions(
projectDir = info.projectDir,
- file = info.filePath,
+ file = filePath,
strategy = DocscribeStrategy.CHECK,
formatJson = true,
)
val result = DocscribeDaemon.executeWithFallback(info.project, options)
+
+ // Another check for the same file started while this one was running — discard
+ if (fileGeneration[filePath] != generation) return null
+
val output =
when {
!result.success -> null
@@ -130,7 +150,7 @@ class DocscribeAnnotator : ExternalAnnotator
}
if (output != null) {
- cache.put(info.projectDir, info.filePath, info.fileStamp, info.configHash, output)
+ cache.put(info.projectDir, filePath, info.fileStamp, info.configHash, output)
}
return if (output == null || output.files.isEmpty()) null else output
}
@@ -172,4 +192,16 @@ class DocscribeAnnotator : ExternalAnnotator
}
}
}
+
+ companion object {
+ /**
+ * Generation counter per file path.
+ *
+ * Incremented each time [doAnnotate] starts for a file. When an annotation completes,
+ * its generation is compared to the current value — if they differ, a newer check ran
+ * and the stale result is discarded (see [doAnnotate]).
+ */
+ @VisibleForTesting
+ internal val fileGeneration = ConcurrentHashMap()
+ }
}
diff --git a/src/main/kotlin/com/florexlabs/docscribe/runner/DocscribeRunner.kt b/src/main/kotlin/com/florexlabs/docscribe/runner/DocscribeRunner.kt
index 3a65291..995e312 100644
--- a/src/main/kotlin/com/florexlabs/docscribe/runner/DocscribeRunner.kt
+++ b/src/main/kotlin/com/florexlabs/docscribe/runner/DocscribeRunner.kt
@@ -82,6 +82,10 @@ interface CommandExecutor {
* Default [CommandExecutor] that uses IntelliJ's [GeneralCommandLine] and [CapturingProcessHandler].
*
* Applies a 120-second timeout. Exit code 2 is treated as a failure (indistinguishable from timeout).
+ *
+ * @property environment Environment variables to pass to the child process.
+ * Merged with the parent process environment; keys in this map take precedence.
+ * Use to inject SDK paths (e.g. prepend Ruby SDK bin dir to `PATH`).
*/
class DefaultCommandExecutor(
private val environment: Map = emptyMap(),
diff --git a/src/test/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotatorTest.kt b/src/test/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotatorTest.kt
index d774da5..8e9f21c 100644
--- a/src/test/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotatorTest.kt
+++ b/src/test/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotatorTest.kt
@@ -35,4 +35,43 @@ class DocscribeAnnotatorTest : BasePlatformTestCase() {
assertNotNull(info)
assertEquals("test.rb", info!!.filePath.substringAfterLast("/"))
}
+
+ fun testDoAnnotateReturnsNullWhenDocscribeNotAvailable() {
+ val annotator = DocscribeAnnotator()
+ val file = myFixture.configureByText("test.rb", "class Foo\nend")
+ val info = annotator.collectInformation(file)!!
+ // No docscribe gem in test env — should return null without throwing
+ val result = annotator.doAnnotate(info)
+ assertNull(result)
+ }
+
+ fun testFileGenerationIncrementsOnNewAnnotation() {
+ DocscribeAnnotator.fileGeneration.clear()
+ val annotator = DocscribeAnnotator()
+ val file = myFixture.configureByText("test.rb", "class Foo\nend")
+ val info = annotator.collectInformation(file)!!
+ val filePath = info.filePath
+
+ assertEquals(0L, DocscribeAnnotator.fileGeneration.getOrDefault(filePath, 0L))
+ annotator.doAnnotate(info)
+ assertEquals(1L, DocscribeAnnotator.fileGeneration.getOrDefault(filePath, 0L))
+ annotator.doAnnotate(info)
+ assertEquals(2L, DocscribeAnnotator.fileGeneration.getOrDefault(filePath, 0L))
+ }
+
+ fun testFileGenerationSeparatePerFile() {
+ DocscribeAnnotator.fileGeneration.clear()
+ val annotator = DocscribeAnnotator()
+ val file1 = myFixture.configureByText("foo.rb", "class Foo\nend")
+ val file2 = myFixture.configureByText("bar.rb", "class Bar\nend")
+ val info1 = annotator.collectInformation(file1)!!
+ val info2 = annotator.collectInformation(file2)!!
+
+ annotator.doAnnotate(info1)
+ annotator.doAnnotate(info2)
+ annotator.doAnnotate(info1)
+
+ assertEquals(2L, DocscribeAnnotator.fileGeneration.getOrDefault(info1.filePath, 0L))
+ assertEquals(1L, DocscribeAnnotator.fileGeneration.getOrDefault(info2.filePath, 0L))
+ }
}
diff --git a/src/test/kotlin/com/florexlabs/docscribe/runner/DocscribeRunnerTest.kt b/src/test/kotlin/com/florexlabs/docscribe/runner/DocscribeRunnerTest.kt
index cc7cea6..9b48fb0 100644
--- a/src/test/kotlin/com/florexlabs/docscribe/runner/DocscribeRunnerTest.kt
+++ b/src/test/kotlin/com/florexlabs/docscribe/runner/DocscribeRunnerTest.kt
@@ -86,6 +86,26 @@ class DocscribeRunnerTest {
}
}
+ @Test
+ fun `DefaultCommandExecutor with environment does not throw`() {
+ val executor = DefaultCommandExecutor(mapOf("PATH" to "/test/bin:/usr/bin"))
+ val tempDir = createTempDirectory().toFile()
+ try {
+ File(tempDir, "Gemfile").writeText(
+ """
+ source "https://rubygems.org"
+ gem "docscribe"
+ """.trimIndent(),
+ )
+ DocscribeRunner.runDocscribe(
+ RunOptions(projectDir = tempDir.absolutePath, strategy = DocscribeStrategy.CHECK),
+ executor,
+ )
+ } finally {
+ tempDir.deleteRecursively()
+ }
+ }
+
@Test
fun `runDocscribe with no file uses no file arg`() {
val executor = RecordingExecutor()
From 15942cd865efb3d2d954e192ac55a1c6ecbf9f41 Mon Sep 17 00:00:00 2001
From: "[NSObject init]" <49247588+unurgunite@users.noreply.github.com>
Date: Sun, 5 Jul 2026 22:32:57 +0300
Subject: [PATCH 7/9] [0.1.5] Add docscribe capability detection: parse
version, skip server on `< 1.5.1` (#34)
---
CHANGELOG.md | 4 +
README.md | 11 +-
config/detekt/detekt.yml | 1 -
.../docscribe/folding/YardFoldingBuilder.kt | 45 +++----
.../docscribe/runner/DocscribeDaemon.kt | 55 +++++++-
.../docscribe/runner/DocscribeOutputParser.kt | 120 ++++++++++++------
.../runner/DocscribeDaemonGemTest.kt | 66 ++++++++++
7 files changed, 234 insertions(+), 68 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cb97835..d91cfc2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,10 @@
### Added
+- **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
+ in companion object with 8 unit tests.
- **Rakefile support (no extension)** — all annotators, actions, and intention actions now accept
a file named `Rakefile` (without `.rb` or `.rake` extension) in addition to `.rb` and `.rake`
files. This ensures the plugin works on standard Rakefiles that have no file extension.
diff --git a/README.md b/README.md
index bfa27fa..16423e7 100644
--- a/README.md
+++ b/README.md
@@ -27,7 +27,7 @@
**DocScribe** is a RubyMine plugin that auto-generates inline YARD documentation for Ruby methods
using [docscribe](https://github.com/unurgunite/docscribe) — a Ruby gem that analyzes AST and suggests YARD-compatible
-documentation. Compatible with **docscribe >= 1.4.0** (daemon mode requires >= 1.5.0).
+documentation. Compatible with **docscribe >= 1.4.0** (daemon mode requires >= 1.5.1).
> Also, available for [VS Code](https://github.com/FlorexLabs/docscribe-vscode) on
> the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=FlorexLabs.docscribe).
@@ -60,7 +60,8 @@ documentation. Compatible with **docscribe >= 1.4.0** (daemon mode requires >= 1
- **Update types from RBS** — refresh YARD docs from RBS signatures
- **Collapsible YARD docs** — fold all YARD comment blocks automatically on file open (configurable in settings)
- **Configurable** — hide comments by default
-- **`.rake` support** — diagnostics and actions work on Rake files
+- **`.rake` and `Rakefile` support** — diagnostics and actions work on Rake files and bare Rakefiles
+- **Automatic backend selection** — detects docscribe version on first use, chooses daemon (>= 1.5.1) or CLI fallback
- **JSON output** — uses `docscribe --format json` for reliable diagnostics parsing
## Requirements
@@ -73,11 +74,11 @@ documentation. Compatible with **docscribe >= 1.4.0** (daemon mode requires >= 1
| Mode | docscribe version | Ruby version |
|--------------------------|-------------------|--------------|
-| Daemon (Unix socket RPC) | >= 1.5.0 | >= 3.0 |
+| Daemon (Unix socket RPC) | >= 1.5.1 | >= 3.0 |
| CLI fallback | >= 1.4.0 | >= 2.7 |
-The plugin prefers daemon mode for better performance. If `docscribe < 1.5.0` or the Ruby SDK is unavailable, it
-automatically falls back to spawning the CLI directly.
+The plugin automatically selects the backend based on the detected docscribe version: daemon mode for >= 1.5.1,
+CLI fallback for older versions. If the Ruby SDK is unavailable, it falls back to system PATH Ruby.
```bash
gem install docscribe
diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml
index 6c9fb84..4871e01 100644
--- a/config/detekt/detekt.yml
+++ b/config/detekt/detekt.yml
@@ -11,4 +11,3 @@ style:
complexity:
CyclomaticComplexMethod:
active: true
- allowedComplexity: 20
diff --git a/src/main/kotlin/com/florexlabs/docscribe/folding/YardFoldingBuilder.kt b/src/main/kotlin/com/florexlabs/docscribe/folding/YardFoldingBuilder.kt
index 8edc378..9f4f82b 100644
--- a/src/main/kotlin/com/florexlabs/docscribe/folding/YardFoldingBuilder.kt
+++ b/src/main/kotlin/com/florexlabs/docscribe/folding/YardFoldingBuilder.kt
@@ -85,8 +85,7 @@ class YardFoldingBuilder :
/**
* The placeholder text shown for a folded YARD comment region.
*/
- @Suppress("NullableReturnType")
- override fun getPlaceholderText(node: ASTNode): String? = " // ..."
+ override fun getPlaceholderText(node: ASTNode): String = " // ..."
/**
* Whether YARD comment regions should be collapsed by default.
@@ -99,34 +98,36 @@ class YardFoldingBuilder :
}
}
+private val yardTags =
+ listOf(
+ "@param",
+ "@return",
+ "@example",
+ "@raise",
+ "@see",
+ "@since",
+ "@version",
+ "@yield",
+ "@option",
+ "@overload",
+ "@note",
+ "@todo",
+ "@deprecated",
+ "@abstract",
+ "@attr_reader",
+ "@attr_writer",
+ "@attr_accessor",
+ )
+
/**
* Check whether a line contains any known YARD tag.
*
* Strips leading `#` and space characters, then checks for tag prefixes.
- * Recognises: @param, @return, @example, @raise, @see, @since, @version,
- * @yield, @option, @overload, @note, @todo, @deprecated, @abstract,
- * @attr_reader, @attr_writer, @attr_accessor.
*
* @param line A single line from the file (may include leading whitespace and `#`).
* @return `true` if the line contains a recognised YARD tag.
*/
private fun containsYardTag(line: String): Boolean {
val trimmed = line.trimStart('#', ' ').trimStart()
- return trimmed.startsWith("@param") ||
- trimmed.startsWith("@return") ||
- trimmed.startsWith("@example") ||
- trimmed.startsWith("@raise") ||
- trimmed.startsWith("@see") ||
- trimmed.startsWith("@since") ||
- trimmed.startsWith("@version") ||
- trimmed.startsWith("@yield") ||
- trimmed.startsWith("@option") ||
- trimmed.startsWith("@overload") ||
- trimmed.startsWith("@note") ||
- trimmed.startsWith("@todo") ||
- trimmed.startsWith("@deprecated") ||
- trimmed.startsWith("@abstract") ||
- trimmed.startsWith("@attr_reader") ||
- trimmed.startsWith("@attr_writer") ||
- trimmed.startsWith("@attr_accessor")
+ return yardTags.any { trimmed.startsWith(it) }
}
diff --git a/src/main/kotlin/com/florexlabs/docscribe/runner/DocscribeDaemon.kt b/src/main/kotlin/com/florexlabs/docscribe/runner/DocscribeDaemon.kt
index aa8a21b..6c8c933 100644
--- a/src/main/kotlin/com/florexlabs/docscribe/runner/DocscribeDaemon.kt
+++ b/src/main/kotlin/com/florexlabs/docscribe/runner/DocscribeDaemon.kt
@@ -78,6 +78,27 @@ class DocscribeDaemon(
/** Tracks whether the `docscribe` gem is installed in the project. */
enum class DocscribeStatus { UNCHECKED, AVAILABLE, MISSING }
+ /**
+ * Parsed docscribe version and derived capabilities.
+ *
+ * Populated by [performGemCheck] when the gem is found.
+ * Used by [ensureRunning] to decide whether server mode is available.
+ */
+ @Volatile
+ @VisibleForTesting
+ internal var capabilities: DocscribeCapabilities? = null
+
+ /**
+ * Capabilities detected from the docscribe version.
+ *
+ * @property version Full version string (e.g. `"1.5.1"`).
+ * @property serverMode Server/daemon mode supported (version >= 1.5.1).
+ */
+ data class DocscribeCapabilities(
+ val version: String,
+ val serverMode: Boolean,
+ )
+
/**
* Internal handle holding the server process and its Unix socket path.
*
@@ -251,6 +272,12 @@ class DocscribeDaemon(
return null
}
+ // Versions before 1.5.1 don't support server mode — fall back to CLI
+ if (capabilities != null && !capabilities!!.serverMode) {
+ log.info("docscribe ${capabilities!!.version} does not support server mode, using CLI")
+ return null
+ }
+
val ruby = rubyCommand()
val gemRoot = if (ruby != null) DocscribeRunner.findProjectRoot(projectDir ?: project.basePath ?: "") else null
val proc = if (gemRoot != null) startServerProcess(ruby!!, gemRoot) else null
@@ -398,7 +425,13 @@ class DocscribeDaemon(
}
if (proc.exitValue() == 0) {
docscribeStatus = DocscribeStatus.AVAILABLE
- log.info("docscribe gem detected (version check passed)")
+ val versionOutput =
+ proc.inputStream
+ .bufferedReader()
+ .readText()
+ .trim()
+ capabilities = parseVersion(versionOutput)
+ log.info("docscribe gem detected (version: ${capabilities?.version ?: versionOutput})")
} else {
val output = proc.inputStream.bufferedReader().readText()
log.warn("docscribe version check failed: exit ${proc.exitValue()}, output: $output")
@@ -789,5 +822,25 @@ class DocscribeDaemon(
formatJson = options.formatJson,
)
}
+
+ /**
+ * Parse docscribe version from `--version` output.
+ *
+ * Handles versions like `1.5.0`, `1.5.1`, or `1.5.0\n` (trailing newline).
+ * Returns `null` for unparseable output (treated as unknown version, server mode disabled).
+ *
+ * @param versionOutput The trimmed stdout from `bundle exec docscribe --version`.
+ * @return [DocscribeCapabilities] with server mode enabled for version >= 1.5.1.
+ */
+ @JvmStatic
+ fun parseVersion(versionOutput: String): DocscribeCapabilities? {
+ val version = versionOutput.trim().takeIf { it.matches(Regex("""\d+\.\d+\.\d+""")) } ?: return null
+ val parts = version.split(".").map { it.toIntOrNull() ?: return null }
+ val serverMode =
+ parts.size == 3 && (
+ parts[0] > 1 || (parts[0] == 1 && parts[1] > 5) || (parts[0] == 1 && parts[1] == 5 && parts[2] >= 1)
+ )
+ return DocscribeCapabilities(version = version, serverMode = serverMode)
+ }
}
}
diff --git a/src/main/kotlin/com/florexlabs/docscribe/runner/DocscribeOutputParser.kt b/src/main/kotlin/com/florexlabs/docscribe/runner/DocscribeOutputParser.kt
index 7708eb0..388edbc 100644
--- a/src/main/kotlin/com/florexlabs/docscribe/runner/DocscribeOutputParser.kt
+++ b/src/main/kotlin/com/florexlabs/docscribe/runner/DocscribeOutputParser.kt
@@ -148,24 +148,39 @@ object DocscribeOutputParser {
val typeMismatchFiles = mutableListOf()
val errorFiles = mutableListOf()
+ processOutputLines(lines, wouldUpdateFiles, typeMismatchFiles, errorFiles)
+
+ return TextParseResult(summary, wouldUpdateFiles, typeMismatchFiles, errorFiles)
+ }
+
+ /**
+ * Process lines after the summary, collecting would-update, type-mismatch and error entries.
+ *
+ * Mutates the provided lists directly. Tracks current "Would update:" file path and its
+ * change details across consecutive lines.
+ */
+ @Suppress("CyclomaticComplexMethod")
+ private fun processOutputLines(
+ lines: List,
+ wouldUpdateFiles: MutableList>>,
+ typeMismatchFiles: MutableList,
+ errorFiles: MutableList,
+ ) {
var currentWouldUpdate: String? = null
var currentDetails = mutableListOf()
for (line in lines) {
when {
line.startsWith("Would update:") -> {
- if (currentWouldUpdate != null) {
- wouldUpdateFiles.add(currentWouldUpdate to currentDetails.toList())
- currentDetails = mutableListOf()
- }
+ val prev = finalizeWouldUpdate(currentWouldUpdate, currentDetails)
+ if (prev != null) wouldUpdateFiles.add(prev)
currentWouldUpdate = wouldUpdateRegex.find(line)?.groupValues?.getOrNull(1)
+ currentDetails = mutableListOf()
}
- changeDetailRegex.matches(line) -> {
+ changeDetailRegex.matches(line) && currentWouldUpdate != null -> {
val detail = changeDetailRegex.find(line)?.groupValues?.getOrNull(1)
- if (detail != null && currentWouldUpdate != null) {
- currentDetails.add(detail)
- }
+ if (detail != null) currentDetails.add(detail)
}
line.startsWith("Type mismatches:") -> {
@@ -182,10 +197,20 @@ object DocscribeOutputParser {
if (currentWouldUpdate != null) {
wouldUpdateFiles.add(currentWouldUpdate to currentDetails.toList())
}
-
- return TextParseResult(summary, wouldUpdateFiles, typeMismatchFiles, errorFiles)
}
+ /**
+ * Finalize a "Would update:" entry by pairing the file path with its details.
+ *
+ * @param filePath The current file being tracked, or `null`.
+ * @param details The details accumulated for this file.
+ * @return A pair of (filePath, details) if [filePath] is not null, or `null`.
+ */
+ private fun finalizeWouldUpdate(
+ filePath: String?,
+ details: MutableList,
+ ): Pair>? = if (filePath != null) filePath to details.toList() else null
+
/**
* Parse a single "Docscribe:" summary line into a [TextSummary].
*
@@ -195,35 +220,52 @@ object DocscribeOutputParser {
* - UPDATED: `Docscribe: updated N file(s)`
*/
private fun parseSummaryLine(line: String): TextSummary? {
- okRegex.find(line)?.let { m ->
- val inspected = m.groupValues[1].toIntOrNull() ?: 0
- val typeMismatches = m.groupValues.getOrNull(2)?.toIntOrNull() ?: 0
- return TextSummary(
- status = "OK",
- inspectedCount = inspected,
- typeMismatchCount = typeMismatches,
- )
- }
- failedRegex.find(line)?.let { m ->
- return TextSummary(
- status = "FAILED",
- needsUpdateCount = m.groupValues[1].toIntOrNull() ?: 0,
- typeMismatchCount = m.groupValues[2].toIntOrNull() ?: 0,
- errorCount = m.groupValues[3].toIntOrNull() ?: 0,
- okCount = m.groupValues[4].toIntOrNull() ?: 0,
- inspectedCount =
- (m.groupValues[1].toIntOrNull() ?: 0) +
- (m.groupValues[2].toIntOrNull() ?: 0) +
- (m.groupValues[3].toIntOrNull() ?: 0) +
- (m.groupValues[4].toIntOrNull() ?: 0),
- )
- }
- updatedRegex.find(line)?.let { m ->
- return TextSummary(
- status = "UPDATED",
- updatedCount = m.groupValues[1].toIntOrNull() ?: 0,
- )
- }
+ okRegex.find(line)?.let { m -> return parseOkSummary(m) }
+ failedRegex.find(line)?.let { m -> return parseFailedSummary(m) }
+ updatedRegex.find(line)?.let { m -> return parseUpdatedSummary(m) }
return null
}
+
+ /**
+ * Parse an "OK" summary line into a [TextSummary].
+ *
+ * Format: `Docscribe: OK (N files checked, M type mismatches)`.
+ */
+ private fun parseOkSummary(m: MatchResult): TextSummary =
+ TextSummary(
+ status = "OK",
+ inspectedCount = m.groupValues[1].toIntOrNull() ?: 0,
+ typeMismatchCount = m.groupValues.getOrNull(2)?.toIntOrNull() ?: 0,
+ )
+
+ /**
+ * Parse a "FAILED" summary line into a [TextSummary].
+ *
+ * Format: `Docscribe: FAILED (N need updates, M type mismatches, E errors, O ok)`.
+ */
+ private fun parseFailedSummary(m: MatchResult): TextSummary {
+ val needsUpdate = m.groupValues[1].toIntOrNull() ?: 0
+ val typeMismatches = m.groupValues[2].toIntOrNull() ?: 0
+ val errors = m.groupValues[3].toIntOrNull() ?: 0
+ val ok = m.groupValues[4].toIntOrNull() ?: 0
+ return TextSummary(
+ status = "FAILED",
+ needsUpdateCount = needsUpdate,
+ typeMismatchCount = typeMismatches,
+ errorCount = errors,
+ okCount = ok,
+ inspectedCount = needsUpdate + typeMismatches + errors + ok,
+ )
+ }
+
+ /**
+ * Parse an "UPDATED" summary line into a [TextSummary].
+ *
+ * Format: `Docscribe: updated N file(s)`.
+ */
+ private fun parseUpdatedSummary(m: MatchResult): TextSummary =
+ TextSummary(
+ status = "UPDATED",
+ updatedCount = m.groupValues[1].toIntOrNull() ?: 0,
+ )
}
diff --git a/src/test/kotlin/com/florexlabs/docscribe/runner/DocscribeDaemonGemTest.kt b/src/test/kotlin/com/florexlabs/docscribe/runner/DocscribeDaemonGemTest.kt
index febc296..b4a3aff 100644
--- a/src/test/kotlin/com/florexlabs/docscribe/runner/DocscribeDaemonGemTest.kt
+++ b/src/test/kotlin/com/florexlabs/docscribe/runner/DocscribeDaemonGemTest.kt
@@ -45,4 +45,70 @@ class DocscribeDaemonGemTest : BasePlatformTestCase() {
val daemon = DocscribeDaemon(project)
assertEquals(DocscribeDaemon.DocscribeStatus.UNCHECKED, daemon.docscribeStatus)
}
+
+ // ── version parsing ──────────────────────────────────────────────
+
+ fun testParseVersion150NoServerMode() {
+ val caps = DocscribeDaemon.parseVersion("1.5.0")
+ assertNotNull(caps)
+ assertEquals("1.5.0", caps!!.version)
+ assertFalse(caps.serverMode)
+ }
+
+ fun testParseVersion151ServerMode() {
+ val caps = DocscribeDaemon.parseVersion("1.5.1")
+ assertNotNull(caps)
+ assertEquals("1.5.1", caps!!.version)
+ assertTrue(caps.serverMode)
+ }
+
+ fun testParseVersion160ServerMode() {
+ val caps = DocscribeDaemon.parseVersion("1.6.0")
+ assertNotNull(caps)
+ assertTrue(caps!!.serverMode)
+ }
+
+ fun testParseVersion200ServerMode() {
+ val caps = DocscribeDaemon.parseVersion("2.0.0")
+ assertNotNull(caps)
+ assertTrue(caps!!.serverMode)
+ }
+
+ fun testParseVersion149NoServerMode() {
+ val caps = DocscribeDaemon.parseVersion("1.4.9")
+ assertNotNull(caps)
+ assertFalse(caps!!.serverMode)
+ }
+
+ fun testParseVersionHandlesTrailingNewline() {
+ val caps = DocscribeDaemon.parseVersion("1.5.1\n")
+ assertNotNull(caps)
+ assertTrue(caps!!.serverMode)
+ }
+
+ fun testParseVersionReturnsNullForGarbage() {
+ assertNull(DocscribeDaemon.parseVersion(""))
+ assertNull(DocscribeDaemon.parseVersion("not-a-version"))
+ assertNull(DocscribeDaemon.parseVersion("v1.5.1"))
+ }
+
+ // ── capability integration ───────────────────────────────────────
+
+ fun testExecuteFallsBackToCliWhenNoServerMode() {
+ val daemon = DocscribeDaemon(project)
+ daemon.docscribeStatus = DocscribeDaemon.DocscribeStatus.AVAILABLE
+ daemon.capabilities = DocscribeDaemon.DocscribeCapabilities("1.5.0", serverMode = false)
+ // 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
+ val result = daemon.execute("check", file = "test.rb", projectDir = "/tmp")
+ // Without a Gemfile, fallback may fail — just ensure no crash
+ assertNotNull(result)
+ }
+
+ fun testCapabilitiesDefaultsToNull() {
+ val daemon = DocscribeDaemon(project)
+ assertNull(daemon.capabilities)
+ }
}
From 795e5fb5b4d80cf2cc272cb47ee1fc837ab4b18b Mon Sep 17 00:00:00 2001
From: "[NSObject init]" <49247588+unurgunite@users.noreply.github.com>
Date: Sun, 5 Jul 2026 23:52:53 +0300
Subject: [PATCH 8/9] [0.1.5] Add Doctor diagnostics action (#35)
---
CHANGELOG.md | 4 +
.../docscribe/actions/DoctorAction.kt | 191 ++++++++++++++++++
.../docscribe/runner/DocscribeDaemon.kt | 32 +++
src/main/resources/META-INF/plugin.xml | 7 +
.../docscribe/actions/DoctorActionTest.kt | 37 ++++
.../runner/DocscribeDaemonGemTest.kt | 2 +-
6 files changed, 272 insertions(+), 1 deletion(-)
create mode 100644 src/main/kotlin/com/florexlabs/docscribe/actions/DoctorAction.kt
create mode 100644 src/test/kotlin/com/florexlabs/docscribe/actions/DoctorActionTest.kt
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)
From 595f6b9457570ec3829a3c34c2497cfdc5634796 Mon Sep 17 00:00:00 2001
From: "[NSObject init]" <49247588+unurgunite@users.noreply.github.com>
Date: Mon, 6 Jul 2026 10:59:20 +0300
Subject: [PATCH 9/9] [0.1.5] Update change-notes for v0.1.5 release (#36)
---
CHANGELOG.md | 3 ++-
src/main/resources/META-INF/plugin.xml | 24 ++++++++++++++++++++++++
2 files changed, 26 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 784f794..ebe4081 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,6 @@
# Changelog
-## [0.1.5] — 2026-07-05
+## [0.1.5] — 2026-07-06
### Added
@@ -55,6 +55,7 @@
- **`intellijDependencies()`** — added to repository section in `build.gradle.kts` (required by
`instrumentTestCode` task).
+- **Total test count** — 119 tests across 18 test files, all passing.
## [0.1.4] — 2026-06-29
diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml
index aad7f9f..517e468 100644
--- a/src/main/resources/META-INF/plugin.xml
+++ b/src/main/resources/META-INF/plugin.xml
@@ -19,6 +19,30 @@
]]>
0.1.5
+ Added
+
+ - Doctor diagnostics action — menu entry showing Ruby SDK, Gemfile, docscribe gem, daemon state, settings
+ - Rakefile support (no extension) — all actions and annotations work on
Rakefile files
+ - Capability detection — docscribe version parsed on startup; server mode skipped for < 1.5.1
+ - Graceful handling when docscribe gem not installed — notification with “Open Gemfile” action
+
+ Changed
+
+ com.intellij.modules.ruby made optional — plugin installs on IntelliJ IDEA (limited), unblocks Marketplace
+ - All commands use project Ruby SDK — SDK
bin/ on PATH, BUNDLE_GEMFILE set
+ - Concurrency guard for rapid annotations — per-file generation discards stale results
+ - Annotation cache respects settings —
configHash derived from hideCommentsByDefault
+ - Version bumped from
0.1.4 to 0.1.5
+
+ Fixed
+
+ - Annotation cache not invalidated on settings change — added clear on save
+
+ Build
+
+ intellijDependencies() added to repository section in build.gradle.kts
+
0.1.4
Fixed