diff --git a/src/main/kotlin/org/move/ide/docs/MvDocumentationProvider.kt b/src/main/kotlin/org/move/ide/docs/MvDocumentationProvider.kt index b10f7bd5..551ce0bf 100644 --- a/src/main/kotlin/org/move/ide/docs/MvDocumentationProvider.kt +++ b/src/main/kotlin/org/move/ide/docs/MvDocumentationProvider.kt @@ -85,11 +85,8 @@ class MvDocumentationProvider : AbstractDocumentationProvider() { } fun MvDocAndAttributeOwner.documentationAsHtml(): String { - return docComments() - .flatMap { it.text.split("\n") } - .map { it.trimStart('/', ' ') } - .map { "

$it

" } - .joinToString("\n") + val commentText = docComments().map { it.text }.joinToString("\n") + return documentationAsHtml(commentText, this) } fun generateFunction(function: MvFunction, buffer: StringBuilder) { diff --git a/src/main/kotlin/org/move/ide/docs/MvRenderedDocumentationPipeline.kt b/src/main/kotlin/org/move/ide/docs/MvRenderedDocumentationPipeline.kt new file mode 100644 index 00000000..72e11995 --- /dev/null +++ b/src/main/kotlin/org/move/ide/docs/MvRenderedDocumentationPipeline.kt @@ -0,0 +1,162 @@ +package org.move.ide.docs + +import com.intellij.codeEditor.printing.HTMLTextPainter +import com.intellij.openapi.editor.colors.EditorColorsManager +import com.intellij.openapi.editor.colors.EditorColorsScheme +import com.intellij.psi.PsiElement +import com.intellij.ui.ColorHexUtil +import com.intellij.ui.ColorUtil +import org.intellij.markdown.IElementType +import org.intellij.markdown.MarkdownElementTypes +import org.intellij.markdown.MarkdownTokenTypes +import org.intellij.markdown.ast.ASTNode +import org.intellij.markdown.ast.getTextInNode +import org.intellij.markdown.flavours.MarkdownFlavourDescriptor +import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor +import org.intellij.markdown.html.* +import org.intellij.markdown.parser.LinkMap +import org.intellij.markdown.parser.MarkdownParser +import java.net.URI + +enum class MvDocRenderMode { + QUICK_DOC_POPUP, + INLINE_DOC_COMMENT, +} + +fun documentationAsHtml(text: String, context: PsiElement): String { + val documentationText = processDocumentationText(text) + val flavour = MvDocMarkdownFlavourDescriptor(context, null, MvDocRenderMode.QUICK_DOC_POPUP) + val root = MarkdownParser(flavour).buildMarkdownTreeFromString(documentationText) + return HtmlGenerator(documentationText, root, flavour).generateHtml() +} + +fun processDocumentationText(text: String): String { + // Comments spanning multiple lines are merged using spaces, unless + // + // - the line is empty + // - the line ends with a . (end of sentence) + // - the line is purely of at least 3 of -, =, _, *, ~ (horizontal rule) + // - the line starts with at least one # followed by a space (header) + // - the line starts and ends with a | (table) + // - the line starts with - (list) + + var insideCodeBlock = false + val lines = text.lines() + val newLines = lines.map { it.trimStart('/', ' ') }.map { line -> + if (line.startsWith("```")) { + insideCodeBlock = !insideCodeBlock + } else if (insideCodeBlock) { + // don't add any extra new lines to code blocks + return@map line + } + + if (ORDERED_LIST_REGEX.matches(line)) { + line // don't add any extra new lines to lists + } else if (line.endsWith(".") || line.endsWith("!") || line.endsWith("?") || + line.matches(Regex("^[-=_*~]{3,}\$")) || + line.endsWith("|") || + line.startsWith("|") || + line.startsWith("-") + ) { + line + "\n\n" + } else { + line + } + } + + return newLines.joinToString("\n") +} + +val ORDERED_LIST_REGEX = """^(\d+\.|-|\*)\s.*$""".toRegex() + +private class MvDocMarkdownFlavourDescriptor( + private val context: PsiElement, + private val uri: URI? = null, + private val renderMode: MvDocRenderMode, + private val gfm: MarkdownFlavourDescriptor = GFMFlavourDescriptor(useSafeLinks = false, absolutizeAnchorLinks = true), +) : MarkdownFlavourDescriptor by gfm { + + override fun createHtmlGeneratingProviders(linkMap: LinkMap, baseURI: URI?): Map { + val generatingProviders = HashMap(gfm.createHtmlGeneratingProviders(linkMap, uri ?: baseURI)) + // Filter out MARKDOWN_FILE to avoid producing unnecessary tags + generatingProviders.remove(MarkdownElementTypes.MARKDOWN_FILE) + // h1 and h2 are too large + generatingProviders[MarkdownElementTypes.ATX_1] = SimpleTagProvider("h2") + generatingProviders[MarkdownElementTypes.ATX_2] = SimpleTagProvider("h3") + generatingProviders[MarkdownElementTypes.CODE_FENCE] = MvCodeFenceProvider(context, renderMode) + + return generatingProviders + } +} + +private class MvCodeFenceProvider( + private val context: PsiElement, + private val renderMode: MvDocRenderMode, +) : GeneratingProvider { + + override fun processNode(visitor: HtmlGenerator.HtmlGeneratingVisitor, text: String, node: ASTNode) { + val indentBefore = node.getTextInNode(text).commonPrefixWith(" ".repeat(10)).length + + val codeText = StringBuilder() + + var childrenToConsider = node.children + if (childrenToConsider.last().type == MarkdownTokenTypes.CODE_FENCE_END) { + childrenToConsider = childrenToConsider.subList(0, childrenToConsider.size - 1) + } + + var isContentStarted = false + + loop@ for (child in childrenToConsider) { + if (isContentStarted && child.type in listOf(MarkdownTokenTypes.CODE_FENCE_CONTENT, MarkdownTokenTypes.EOL)) { + val rawLine = HtmlGenerator.trimIndents(child.getTextInNode(text), indentBefore) + codeText.append(rawLine) + } + + if (!isContentStarted && child.type == MarkdownTokenTypes.EOL) { + isContentStarted = true + } + } + + visitor.consumeHtml(convertToHtmlWithHighlighting(codeText.toString())) + } + + private fun convertToHtmlWithHighlighting(codeText: String): String { + var htmlCodeText = HTMLTextPainter.convertCodeFragmentToHTMLFragmentWithInlineStyles(context, codeText) + + val scheme = EditorColorsManager.getInstance().globalScheme + htmlCodeText = htmlCodeText.replaceFirst( + "
",
+            "
"
+        )
+
+        return when (renderMode) {
+            MvDocRenderMode.INLINE_DOC_COMMENT -> htmlCodeText.dimColors(scheme)
+            else                               -> htmlCodeText
+        }
+    }
+
+    private fun String.dimColors(scheme: EditorColorsScheme): String {
+        val alpha = if (isColorSchemeDark(scheme)) DARK_THEME_ALPHA else LIGHT_THEME_ALPHA
+
+        return replace(COLOR_PATTERN) { result ->
+            val colorHexValue = result.groupValues[1]
+            val fgColor = ColorHexUtil.fromHexOrNull(colorHexValue) ?: return@replace result.value
+            val bgColor = scheme.defaultBackground
+            val finalColor = ColorUtil.mix(bgColor, fgColor, alpha)
+
+            "color: #${ColorUtil.toHex(finalColor)}"
+        }
+    }
+
+    private fun isColorSchemeDark(scheme: EditorColorsScheme): Boolean {
+        return ColorUtil.isDark(scheme.defaultBackground)
+    }
+
+    companion object {
+        private val COLOR_PATTERN = """color:\s*#(\p{XDigit}{3,})""".toRegex()
+
+        private const val CODE_SNIPPET_INDENT = 10
+        private const val LIGHT_THEME_ALPHA = 0.6
+        private const val DARK_THEME_ALPHA = 0.78
+    }
+}