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("").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(" |
")
+ }
+
+ 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("
")
+ 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")
+ }
}