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

+ +

Changed

+ +

Fixed

+ +

Build

+

0.1.4

Fixed