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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@

### Added

- **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.
Expand Down
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
1 change: 0 additions & 1 deletion config/detekt/detekt.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,3 @@ style:
complexity:
CyclomaticComplexMethod:
active: true
allowedComplexity: 20
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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) }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -148,24 +148,39 @@ object DocscribeOutputParser {
val typeMismatchFiles = mutableListOf<String>()
val errorFiles = mutableListOf<String>()

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<String>,
wouldUpdateFiles: MutableList<Pair<String, List<String>>>,
typeMismatchFiles: MutableList<String>,
errorFiles: MutableList<String>,
) {
var currentWouldUpdate: String? = null
var currentDetails = mutableListOf<String>()

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:") -> {
Expand All @@ -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<String>,
): Pair<String, List<String>>? = if (filePath != null) filePath to details.toList() else null

/**
* Parse a single "Docscribe:" summary line into a [TextSummary].
*
Expand All @@ -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,
)
}
Loading
Loading