From f12573b3baab758a03ce20114ffdea0ac67ebd9a Mon Sep 17 00:00:00 2001 From: unurgunite Date: Wed, 9 Sep 2026 00:49:38 +0300 Subject: [PATCH 1/2] [0.1.7] Fix error display when docscribe fails and trailing literal suppresses warnings --- .../docscribe/annotator/DocscribeAnnotator.kt | 153 +++++++++++++++--- .../DocscribeAnnotatorFileStampTest.kt | 18 ++- .../annotator/DocscribeAnnotatorTest.kt | 18 ++- 3 files changed, 164 insertions(+), 25 deletions(-) diff --git a/src/main/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotator.kt b/src/main/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotator.kt index b40357e..408d96b 100644 --- a/src/main/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotator.kt +++ b/src/main/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotator.kt @@ -209,18 +209,121 @@ class DocscribeAnnotator : ExternalAnnotator // Another check for same file started while this one was running — discard if (fileGeneration[filePath] != generation) return null - val output = + val output: DocscribeOutput? = when { - !result.success -> null - result.stdout.isBlank() -> DocscribeOutput(null, emptyList(), null) - else -> DocscribeOutputParser.parseJson(result.stdout) + !result.success -> { + val stderrPrev = result.stderr.take(MAX_STDERR_PREVIEW) + log.warn( + "DocScribe doAnnotate failed for $filePath: " + + "success=${result.success} exit=${result.exitCode} " + + "stderr=$stderrPrev blank=${result.stdout.isBlank()}", + ) + // Don't cache failures — let next annotate retry, and return a synthetic error output + // so apply can show a visible error instead of 0 + val msg = + result.stderr + .ifBlank { + "Docscribe failed (exit ${result.exitCode})" + }.take(MAX_STDERR_PREVIEW) + DocscribeOutput( + metadata = null, + files = + listOf( + com.florexlabs.docscribe.runner.ParsedFile( + path = filePath, + offenses = + listOf( + com.florexlabs.docscribe.runner.ParsedOffense( + severity = "error", + copName = "Docscribe/Error", + message = msg, + corrected = false, + correctable = false, + location = + com.florexlabs.docscribe.runner + .OffenseLocation(1, 1, 1, 1), + ), + ), + ), + ), + summary = null, + ) + } + + result.stdout.isBlank() -> { + DocscribeOutput(null, emptyList(), null) + } + + else -> { + DocscribeOutputParser.parseJson(result.stdout) + } } - log.info("DocScribe doAnnotate parsed output files=${output?.files?.size} offenses=${output?.files?.firstOrNull()?.offenses?.size}") - if (output != null) { + // Handle case where docscribe reported error_count >0 but no files (e.g. parser error) + val hasErrorCount = (output?.summary?.errorCount ?: 0) > 0 + val isEmptyWithError = hasErrorCount && output?.files?.isEmpty() == true + if (isEmptyWithError) { + log.warn("DocScribe doAnnotate error_count>0 but files empty for $filePath, treating as error") + } + // Don't cache emptyList when there was an actual error (e.g. !success case above is not cached anyway) + // For blank stdout with success, it's a legitimate "no offenses" file, cache it + val isErrorOutput = output?.files?.any { it.offenses.any { off -> off.copName == "Docscribe/Error" } } == true || isEmptyWithError + if (output != null && !isErrorOutput) { cache.put(info.projectDir, filePath, info.fileStamp, effectiveHash, output) + } else if (isErrorOutput) { + log.warn("DocScribe doAnnotate not caching error output for $filePath") } - val ret = if (output == null || output.files.isEmpty()) null else output + log.info( + "DocScribe doAnnotate parsed output files=${output?.files?.size} " + + "offenses=${output?.files?.firstOrNull()?.offenses?.size} hasErrorCount=$hasErrorCount", + ) + // Don't treat error output as empty — return it so apply can show the error + val ret = + when { + output == null -> { + null + } + + isErrorOutput -> { + // If we have error_count but no files, create a synthetic error file for apply to show + if (output.files.isEmpty() && hasErrorCount) { + val msg = "Docscribe reported ${output.summary?.errorCount} error(s) for $filePath (check idea.log)" + DocscribeOutput( + metadata = output.metadata, + files = + listOf( + com.florexlabs.docscribe.runner.ParsedFile( + path = filePath, + offenses = + listOf( + com.florexlabs.docscribe.runner.ParsedOffense( + severity = "error", + copName = "Docscribe/Error", + message = msg, + corrected = false, + correctable = false, + location = + com.florexlabs.docscribe.runner + .OffenseLocation(1, 1, 1, 1), + ), + ), + ), + ), + summary = output.summary, + ) + } else { + output + } + } + + output.files.isEmpty() -> { + null + } + + else -> { + output + } + } log.info("DocScribe doAnnotate returning ${if (ret == null) "null" else "${ret.files.size} files"}") return ret } @@ -245,18 +348,20 @@ class DocscribeAnnotator : ExternalAnnotator val offenseCount = annotationResult?.files?.sumOf { it.offenses.size } log.info("DocScribe apply file=$filePath result=${annotationResult?.files?.size} offenses=$offenseCount") val document = PsiDocumentManager.getInstance(file.project).getDocument(file) ?: return - // 1. Daemon offenses (RBS mismatches, missing docs, invalid YARD) + // 1. Daemon offenses (RBS mismatches, missing docs, invalid YARD, errors) if (annotationResult != null) { for (parsedFile in annotationResult.files) { for (offense in parsedFile.offenses) { val isRbsTypeUpdate = offense.copName == "Docscribe/UpdatedParam" || offense.copName == "Docscribe/UpdatedReturn" val isInvalidYard = offense.copName == "Docscribe/InvalidType" + val isError = offense.copName == "Docscribe/Error" val baseLine = (offense.location.startLine - 1).coerceIn(0, document.lineCount - 1) - // For both RBS updates and invalid YARD, highlight the YARD comment, not the def + // For RBS updates and invalid YARD, highlight the YARD comment, not the def. Errors stay on line 1. val line = when { isRbsTypeUpdate -> findYardTagLine(document, baseLine, offense.copName) ?: baseLine isInvalidYard -> findYardTagLine(document, baseLine, offense.copName, offense.message) ?: baseLine + isError -> baseLine else -> baseLine } val lineStart = document.getLineStartOffset(line) @@ -271,17 +376,25 @@ class DocscribeAnnotator : ExternalAnnotator // For RBS type mismatches, safe fix is no-op for existing @param, // so offer update_types which does -AkB + -aB with rbs_collection. // Keeps descriptions via -k. For invalid YARD, offer direct YARD fix. - val fix = - when { - isRbsTypeUpdate -> DocscribeUpdateTypesIntention() - isInvalidYard -> DocscribeInvalidYardTypeFixIntention(offense.message, line) - else -> DocscribeFixIntention() - } - holder - .newAnnotation(severity, offense.message) - .range(range) - .withFix(fix) - .create() + // For errors, don't offer a fix (just show the error). + if (isError) { + holder + .newAnnotation(HighlightSeverity.ERROR, offense.message) + .range(range) + .create() + } else { + val fix = + when { + isRbsTypeUpdate -> DocscribeUpdateTypesIntention() + isInvalidYard -> DocscribeInvalidYardTypeFixIntention(offense.message, line) + else -> DocscribeFixIntention() + } + holder + .newAnnotation(severity, offense.message) + .range(range) + .withFix(fix) + .create() + } } } } diff --git a/src/test/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotatorFileStampTest.kt b/src/test/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotatorFileStampTest.kt index 39a0c4c..3b85c39 100644 --- a/src/test/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotatorFileStampTest.kt +++ b/src/test/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotatorFileStampTest.kt @@ -61,9 +61,21 @@ class DocscribeAnnotatorFileStampTest : BasePlatformTestCase() { "# @param [String] x\n# @return [void]\ndef foo(x)\nend\n", ) val info = annotator.collectInformation(psiFile)!! - // doAnnotate will try to call daemon and likely return null in test env (no gem), but should not throw + // doAnnotate will try to call daemon and likely return error in test env (no gem), but should not throw val result = annotator.doAnnotate(info) - // In test env without daemon, result is null — but the call should not crash and should handle ReadAction save logic - assertNull(result) + // In test env without daemon, result is either null (old) or error output (new) — both acceptable without throw + if (result != null) { + assertEquals(1, result.files.size) + assertEquals( + "Docscribe/Error", + result.files + .first() + .offenses + .first() + .copName, + ) + } else { + assertNull(result) + } } } diff --git a/src/test/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotatorTest.kt b/src/test/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotatorTest.kt index 0c30a16..e4c6e70 100644 --- a/src/test/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotatorTest.kt +++ b/src/test/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotatorTest.kt @@ -40,9 +40,23 @@ class DocscribeAnnotatorTest : BasePlatformTestCase() { 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 + // No docscribe gem in test env — now returns error output instead of null, but should not throw val result = annotator.doAnnotate(info) - assertNull(result) + // In test env without daemon, it returns a synthetic error (Docscribe/Error) which is not cached as empty + // We check that it either returns null (old) or an error output (new) — both are acceptable without throw + if (result != null) { + assertEquals(1, result.files.size) + assertEquals( + "Docscribe/Error", + result.files + .first() + .offenses + .first() + .copName, + ) + } else { + assertNull(result) + } } fun testFileGenerationIncrementsOnNewAnnotation() { From 5cbfafcbe9d9f2f99dd2dbd2beb3fda45d8ca470 Mon Sep 17 00:00:00 2001 From: unurgunite Date: Wed, 9 Sep 2026 13:26:45 +0300 Subject: [PATCH 2/2] [0.1.7] Fix annotator error display and trailing literal cache --- .../docscribe/annotator/DocscribeAnnotator.kt | 67 ++++++++++++++++++- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotator.kt b/src/main/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotator.kt index 408d96b..1b960da 100644 --- a/src/main/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotator.kt +++ b/src/main/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotator.kt @@ -218,8 +218,14 @@ class DocscribeAnnotator : ExternalAnnotator "success=${result.success} exit=${result.exitCode} " + "stderr=$stderrPrev blank=${result.stdout.isBlank()}", ) - // Don't cache failures — let next annotate retry, and return a synthetic error output - // so apply can show a visible error instead of 0 + // For gem not installed, show a balloon with Add action, not a red squiggle + // This matches RuboCop's UX: balloon with "Add to Gemfile" button + if (result.stderr.contains("docscribe gem is not installed", ignoreCase = true)) { + showGemNotInstalledBalloon(info.project, info.projectDir) + return null + } + // For other failures (fatal syntax etc.), return a synthetic error output + // so apply can show a visible error instead of 0. Don't cache failures. val msg = result.stderr .ifBlank { @@ -402,6 +408,63 @@ class DocscribeAnnotator : ExternalAnnotator // (Yard::Validator + TypeMismatchValidator) and appears as Docscribe/InvalidType above } + private fun showGemNotInstalledBalloon( + project: Project, + projectDir: String, + ) { + try { + val group = + com.intellij.notification.NotificationGroupManager + .getInstance() + .getNotificationGroup("DocScribe") ?: return + val notification = + group.createNotification( + "Docscribe gem not found in this project. Add 'gem \"docscribe\"' to Gemfile to enable YARD checks.", + com.intellij.notification.NotificationType.WARNING, + ) + notification.addAction( + object : com.intellij.openapi.actionSystem.AnAction("Add to Gemfile") { + override fun actionPerformed(e: com.intellij.openapi.actionSystem.AnActionEvent) { + val gemFile = java.io.File(projectDir, "Gemfile") + try { + if (!gemFile.exists()) { + gemFile.writeText("source \"https://rubygems.org\"\n\ngem \"docscribe\"\n") + } else { + val content = gemFile.readText() + if (!content.contains("gem \"docscribe\"") && !content.contains("gem 'docscribe'")) { + gemFile.appendText("\ngem \"docscribe\"\n") + } + } + val vFile = + com.intellij.openapi.vfs.LocalFileSystem + .getInstance() + .refreshAndFindFileByIoFile(gemFile) + if (vFile != null) { + com.intellij.openapi.fileEditor.FileEditorManager + .getInstance(project) + .openFile(vFile, true) + } + group + .createNotification( + "Added gem \"docscribe\" to Gemfile — run 'bundle install'", + com.intellij.notification.NotificationType.INFORMATION, + ).notify(project) + } catch (_: Exception) { + group + .createNotification( + "Failed to update Gemfile", + com.intellij.notification.NotificationType.ERROR, + ).notify(project) + } + notification.expire() + } + }, + ) + notification.notify(project) + } catch (_: Exception) { + } + } + private fun findYardTagLine( document: com.intellij.openapi.editor.Document, defLine: Int,