diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/docs/LyngDocumentationProvider.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/docs/LyngDocumentationProvider.kt index 21421be..abe7a72 100644 --- a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/docs/LyngDocumentationProvider.kt +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/docs/LyngDocumentationProvider.kt @@ -518,18 +518,26 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() { if (d.mutable) "var ${d.name}${typeStr}" else "val ${d.name}${typeStr}" } } - // Show full detailed documentation, not just the summary - val raw = d.doc?.raw - val doc: String? = if (raw.isNullOrBlank()) null else MarkdownRenderer.render(raw) val sb = StringBuilder() sb.append("
").append(htmlEscape(title)).append("
") - if (!doc.isNullOrBlank()) sb.append(styledMarkdown(doc)) + sb.append(renderDocBody(d.doc)) return sb.toString() } private fun renderParamDoc(fn: MiniFunDecl, p: MiniParam): String { val title = "parameter ${p.name}${typeOf(p.type)} in ${fn.name}${signatureOf(fn)}" - return "
${htmlEscape(title)}
" + val sb = StringBuilder() + sb.append("
").append(htmlEscape(title)).append("
") + + // Find matching @param tag + fn.doc?.tags?.get("param")?.forEach { tag -> + val parts = tag.split(Regex("\\s+"), 2) + if (parts.getOrNull(0) == p.name && parts.size > 1) { + sb.append(styledMarkdown(MarkdownRenderer.render(parts[1]))) + } + } + + return sb.toString() } private fun renderMemberFunDoc(className: String, m: MiniMemberFunDecl): String { @@ -540,11 +548,9 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() { val ret = typeOf(m.returnType) val staticStr = if (m.isStatic) "static " else "" val title = "${staticStr}method $className.${m.name}(${params})${ret}" - val raw = m.doc?.raw - val doc: String? = if (raw.isNullOrBlank()) null else MarkdownRenderer.render(raw) val sb = StringBuilder() sb.append("
").append(htmlEscape(title)).append("
") - if (!doc.isNullOrBlank()) sb.append(styledMarkdown(doc)) + sb.append(renderDocBody(m.doc)) return sb.toString() } @@ -553,11 +559,9 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() { val kind = if (m.mutable) "var" else "val" val staticStr = if (m.isStatic) "static " else "" val title = "${staticStr}${kind} $className.${m.name}${ts}" - val raw = m.doc?.raw - val doc: String? = if (raw.isNullOrBlank()) null else MarkdownRenderer.render(raw) val sb = StringBuilder() sb.append("
").append(htmlEscape(title)).append("
") - if (!doc.isNullOrBlank()) sb.append(styledMarkdown(doc)) + sb.append(renderDocBody(m.doc)) return sb.toString() } @@ -663,6 +667,55 @@ class LyngDocumentationProvider : AbstractDocumentationProvider() { return if (e > s) TextRange(s, e) else null } + private fun renderDocBody(doc: MiniDoc?): String { + if (doc == null) return "" + val sb = StringBuilder() + if (doc.raw.isNotBlank()) { + sb.append(styledMarkdown(MarkdownRenderer.render(doc.raw))) + } + if (doc.tags.isNotEmpty()) { + sb.append(renderTags(doc.tags)) + } + return sb.toString() + } + + private fun renderTags(tags: Map>): String { + if (tags.isEmpty()) return "" + val sb = StringBuilder() + sb.append("") + + fun section(title: String, list: List, isKeyValue: Boolean = false) { + if (list.isEmpty()) return + sb.append("") + } + + section("Parameters", tags["param"] ?: emptyList(), isKeyValue = true) + section("Returns", tags["return"] ?: emptyList()) + section("Throws", tags["throws"] ?: emptyList(), isKeyValue = true) + + tags.forEach { (name, list) -> + if (name !in listOf("param", "return", "throws")) { + section(name.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }, list) + } + } + + sb.append("

").append(htmlEscape(title)).append(":

") + list.forEachIndexed { index, item -> + if (index > 0) sb.append("

") + if (isKeyValue) { + val parts = item.split(Regex("\\s+"), 2) + sb.append("").append(htmlEscape(parts[0])).append("") + if (parts.size > 1) { + sb.append(" — ").append(MarkdownRenderer.render(parts[1]).removePrefix("

").removeSuffix("

")) + } + } else { + sb.append(MarkdownRenderer.render(item).removePrefix("

").removeSuffix("

")) + } + } + sb.append("
") + return sb.toString() + } + private fun previousWordBefore(text: String, offset: Int): TextRange? { // skip spaces and the dot to the left, but stop after hitting a non-identifier boundary var i = (offset - 1).coerceAtLeast(0) diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt index e23ed08..ad93c31 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt @@ -116,10 +116,8 @@ class Compiler( private fun consumePendingDoc(): MiniDoc? { if (pendingDocLines.isEmpty()) return null - val raw = pendingDocLines.joinToString("\n").trimEnd() - val summary = raw.lines().firstOrNull { it.isNotBlank() }?.trim() val start = pendingDocStart ?: cc.currentPos() - val doc = MiniDoc(MiniRange(start, start), raw = raw, summary = summary) + val doc = MiniDoc.parse(MiniRange(start, start), pendingDocLines) clearPendingDoc() return doc } diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/BuiltinDocRegistry.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/BuiltinDocRegistry.kt index 00d3ce3..78a9c3f 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/BuiltinDocRegistry.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/BuiltinDocRegistry.kt @@ -255,8 +255,7 @@ class ClassDocsBuilder internal constructor(private val className: String) { private fun builtinRange() = MiniRange(Pos.builtIn, Pos.builtIn) private fun miniDoc(text: String, tags: Map>): MiniDoc { - val summary = text.lineSequence().map { it.trim() }.firstOrNull { it.isNotEmpty() } - return MiniDoc(range = builtinRange(), raw = text, summary = summary, tags = tags) + return MiniDoc.parse(builtinRange(), listOf(text), tags) } private fun TypeDoc.toDisplayName(): String = when (this) { diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/MiniAst.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/MiniAst.kt index 9a144e4..aa31d59 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/MiniAst.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/miniast/MiniAst.kt @@ -39,7 +39,56 @@ data class MiniDoc( val raw: String, val summary: String?, val tags: Map> = emptyMap() -) +) { + companion object { + fun parse(range: MiniRange, lines: Iterable, extraTags: Map> = emptyMap()): MiniDoc { + val parsedTags = mutableMapOf>() + var currentTag: String? = null + val currentContent = StringBuilder() + + fun flush() { + currentTag?.let { tag -> + parsedTags.getOrPut(tag) { mutableListOf() }.add(currentContent.toString().trim()) + } + currentContent.setLength(0) + } + + val descriptionLines = mutableListOf() + var inTags = false + + for (rawLine in lines) { + for (line in rawLine.lines()) { + val trimmed = line.trim() + if (trimmed.startsWith("@")) { + inTags = true + flush() + val parts = trimmed.substring(1).split(Regex("\\s+"), 2) + currentTag = parts[0] + currentContent.append(parts.getOrNull(1) ?: "") + } else { + if (inTags) { + if (currentContent.isNotEmpty()) currentContent.append("\n") + currentContent.append(line) + } else { + descriptionLines.add(line) + } + } + } + } + flush() + + val raw = descriptionLines.joinToString("\n").trimEnd() + val summary = raw.lines().firstOrNull { it.isNotBlank() }?.trim() + + val finalTags = parsedTags.toMutableMap() + extraTags.forEach { (k, v) -> + finalTags.getOrPut(k) { mutableListOf() }.addAll(v) + } + + return MiniDoc(range, raw, summary, finalTags) + } + } +} sealed interface MiniNode { val range: MiniRange } diff --git a/lynglib/src/commonTest/kotlin/MiniAstTest.kt b/lynglib/src/commonTest/kotlin/MiniAstTest.kt index 3f6f431..4e3c8f5 100644 --- a/lynglib/src/commonTest/kotlin/MiniAstTest.kt +++ b/lynglib/src/commonTest/kotlin/MiniAstTest.kt @@ -473,4 +473,58 @@ class MiniAstTest { assertEquals("a", fn.params[0].name) assertEquals("b", fn.params[1].name) } + + @Test + fun miniAst_captures_dokka_tags() = runTest { + val code = """ + /** + * Testing tags. + * @param x the x value + * @param y the y value + * @return some string + * @throws Exception if failed + */ + fun tagged(x: Int, y: Int): String { "" } + """.trimIndent() + val (_, sink) = compileWithMini(code) + val mini = sink.build() + assertNotNull(mini) + val fn = mini.declarations.filterIsInstance().firstOrNull { it.name == "tagged" } + assertNotNull(fn) + val doc = fn.doc + assertNotNull(doc) + assertEquals("Testing tags.", doc.summary) + + val tags = doc.tags + assertTrue(tags.containsKey("param"), "should have @param tags") + assertEquals(listOf("x the x value", "y the y value"), tags["param"]) + assertEquals(listOf("some string"), tags["return"]) + assertEquals(listOf("Exception if failed"), tags["throws"]) + } + + @Test + fun miniAst_captures_multiline_tags() = runTest { + val code = """ + /** + * Multi line tag. + * @param x first line of x + * second line of x + * @return return value + */ + fun multiline(x: Int): Int { 0 } + """.trimIndent() + val (_, sink) = compileWithMini(code) + val mini = sink.build() + assertNotNull(mini) + val fn = mini.declarations.filterIsInstance().firstOrNull { it.name == "multiline" } + assertNotNull(fn) + val doc = fn.doc + assertNotNull(doc) + + val tags = doc.tags + assertTrue(tags.containsKey("param"), "should have @param tags") + val xParam = tags["param"]?.first() ?: "" + assertTrue(xParam.contains("first line of x"), "should contain first line") + assertTrue(xParam.contains("second line of x"), "should contain second line") + } }