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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
216 changes: 196 additions & 20 deletions src/main/kotlin/com/florexlabs/docscribe/annotator/DocscribeAnnotator.kt
Original file line number Diff line number Diff line change
Expand Up @@ -209,18 +209,127 @@ class DocscribeAnnotator : ExternalAnnotator<AnnotatorFileInfo, DocscribeOutput>
// 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()}",
)
// 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 {
"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
}
Expand All @@ -245,18 +354,20 @@ class DocscribeAnnotator : ExternalAnnotator<AnnotatorFileInfo, DocscribeOutput>
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)
Expand All @@ -271,24 +382,89 @@ class DocscribeAnnotator : ExternalAnnotator<AnnotatorFileInfo, DocscribeOutput>
// 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()
}
}
}
}
// YARD syntax validation without RBS is now handled by the gem via --validate-types
// (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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading