diff --git a/markdown/core/api/core.api b/markdown/core/api/core.api index 14248ba4f..176a86af5 100644 --- a/markdown/core/api/core.api +++ b/markdown/core/api/core.api @@ -325,8 +325,8 @@ public abstract interface class org/jetbrains/jewel/markdown/extensions/Markdown public final class org/jetbrains/jewel/markdown/processing/MarkdownProcessor { public static final field $stable I public fun ()V - public fun (Ljava/util/List;)V - public synthetic fun (Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/util/List;Z)V + public synthetic fun (Ljava/util/List;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun ([Lorg/jetbrains/jewel/markdown/extensions/MarkdownProcessorExtension;)V public final fun processChildren (Lorg/commonmark/node/Node;)Ljava/util/List; public final fun processMarkdownDocument (Ljava/lang/String;)Ljava/util/List; diff --git a/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/processing/MarkdownProcessor.kt b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/processing/MarkdownProcessor.kt index 5090de9c2..7e838612c 100644 --- a/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/processing/MarkdownProcessor.kt +++ b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/processing/MarkdownProcessor.kt @@ -1,5 +1,6 @@ package org.jetbrains.jewel.markdown.processing +import org.commonmark.node.Block import org.commonmark.node.BlockQuote import org.commonmark.node.BulletList import org.commonmark.node.Code @@ -23,6 +24,7 @@ import org.commonmark.node.SoftLineBreak import org.commonmark.node.StrongEmphasis import org.commonmark.node.Text import org.commonmark.node.ThematicBreak +import org.commonmark.parser.IncludeSourceSpans import org.commonmark.parser.Parser import org.commonmark.renderer.text.TextContentRenderer import org.intellij.lang.annotations.Language @@ -41,19 +43,35 @@ import org.jetbrains.jewel.markdown.MimeType import org.jetbrains.jewel.markdown.extensions.MarkdownProcessorExtension import org.jetbrains.jewel.markdown.rendering.DefaultInlineMarkdownRenderer +/** + * @param optimizeEdits Optional. Indicates whether the processing should only update the changed blocks + * by keeping a previous state in memory. Default is `true`, use `false` for immutable data. + */ @ExperimentalJewelApi -public class MarkdownProcessor(private val extensions: List = emptyList()) { +public class MarkdownProcessor( + private val extensions: List = emptyList(), + private val optimizeEdits: Boolean = true, +) { public constructor(vararg extensions: MarkdownProcessorExtension) : this(extensions.toList()) - private val commonMarkParser = - Parser.builder().extensions(extensions.map { it.parserExtension }).build() + private val commonMarkParser = Parser.builder() + .extensions(extensions.map { it.parserExtension }) + .also { + if (optimizeEdits) { + it.includeSourceSpans(IncludeSourceSpans.BLOCKS) + } + }.build() private val textContentRenderer = TextContentRenderer.builder() .extensions(extensions.map { it.textRendererExtension }) .build() + private data class State(val lines: List, val blocks: List, val indexes: List>) + + private var currentState = State(emptyList(), emptyList(), emptyList()) + /** * Parses a Markdown document, translating from CommonMark 0.31.2 * to a list of [MarkdownBlock]. Inline Markdown in leaf nodes @@ -83,11 +101,87 @@ public class MarkdownProcessor(private val extensions: List { + if (!optimizeEdits) { + return textToBlocks(rawMarkdown).mapNotNull { child -> + child.tryProcessMarkdownBlock() + } + } + val (previousLines, previousBlocks, previousIndexes) = currentState + val newLines = rawMarkdown.lines() + val nLinesDelta = newLines.size - previousLines.size + // find a block prior to the first one changed in case some elements merge during the update + var firstBlock = 0 + var firstLine = 0 + var currFirstBlock = 0 + var currFirstLine = 0 + outerLoop@ for ((i, spans) in previousIndexes.withIndex()) { + val (_, end) = spans + for (j in currFirstLine..end) { + if (newLines[j] != previousLines[j]) { + break@outerLoop + } + } + firstBlock = currFirstBlock + firstLine = currFirstLine + currFirstBlock = i + 1 + currFirstLine = end + 1 + } + // find a block following the last one changed in case some elements merge during the update + var lastBlock = previousBlocks.size + var lastLine = previousLines.size + var currLastBlock = lastBlock + var currLastLine = lastLine + outerLoop@ for ((i, spans) in previousIndexes.withIndex().reversed()) { + val (begin, _) = spans + for (j in begin until currLastLine) { + if (previousLines[j] != newLines[j + nLinesDelta]) { + break@outerLoop + } + } + lastBlock = currLastBlock + lastLine = currLastLine + currLastBlock = i + currLastLine = begin + } + val updatedText = newLines.subList(firstLine, lastLine + nLinesDelta).joinToString("\n", postfix = "\n") + val updatedBlocks: List = textToBlocks(updatedText) + val updatedIndexes = + updatedBlocks.map { node -> + // special case for a bug where LinkReferenceDefinition is a Node, + // but it takes over sourceSpans from the following Block + if (node.sourceSpans.isEmpty()) { + node.sourceSpans = node.previous.sourceSpans + } + (node.sourceSpans.first().lineIndex + firstLine) to + (node.sourceSpans.last().lineIndex + firstLine) + } + val suffixIndexes = previousIndexes.subList(lastBlock, previousBlocks.size).map { + (it.first + nLinesDelta) to (it.second + nLinesDelta) + } + val newBlocks = ( + previousBlocks.subList(0, firstBlock) + + updatedBlocks + + previousBlocks.subList(lastBlock, previousBlocks.size) + ) + val result = newBlocks.mapNotNull { child -> + child.tryProcessMarkdownBlock() + } + val newIndexes = previousIndexes.subList(0, firstBlock) + updatedIndexes + suffixIndexes + currentState = State(newLines, newBlocks, newIndexes) + return result + } + + private fun textToBlocks(strings: String): List { val document = - commonMarkParser.parse(rawMarkdown) as? Document + commonMarkParser.parse(strings) as? Document ?: error("This doesn't look like a Markdown document") - - return processChildren(document) + val updatedBlocks: List = + buildList { + document.forEachChild { child -> + (child as? Block)?.let { add(it) } + } + } + return updatedBlocks } private fun Node.tryProcessMarkdownBlock(): MarkdownBlock? = diff --git a/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/processing/MarkdownProcessorTest.kt b/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/processing/MarkdownProcessorTest.kt new file mode 100644 index 000000000..c3e80fe15 --- /dev/null +++ b/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/processing/MarkdownProcessorTest.kt @@ -0,0 +1,55 @@ +package org.jetbrains.jewel.markdown.processing + +import org.jetbrains.jewel.markdown.BlockWithInlineMarkdown +import org.jetbrains.jewel.markdown.MarkdownBlock +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test + +class MarkdownProcessorTest { + + @Test + fun `test my processor`() { + val pp = MarkdownProcessor() + pp.processMarkdownDocument( + """ + |Paragraph 1 + | + |Paragraph 2 + | + |Second paragraph + |not very important + | + |* m1 + |* m2 + """.trimMargin(), + ) + val secondProcess = pp.processMarkdownDocument( + """ + |Paragraph 1 + | + |Paragraph 2 + | + |Not a paragraph + |not very important + | + |* m1 + |* m2 + """.trimMargin(), + ) + // TODO: update after changing the underlying model, to check the first elements are the same + assertEquals("Paragraph 1", (secondProcess[0] as BlockWithInlineMarkdown).inlineContent.content) + assertEquals("Paragraph 2", (secondProcess[1] as BlockWithInlineMarkdown).inlineContent.content) + assertEquals( + "Not a paragraph not very important", + (secondProcess[2] as BlockWithInlineMarkdown).inlineContent.content, + ) + assertArrayEquals( + arrayOf( + "Paragraph(inlineContent=InlineMarkdown(content=m1))", + "Paragraph(inlineContent=InlineMarkdown(content=m2))", + ), + (secondProcess[3] as MarkdownBlock.ListBlock).items.flatMap { it.content.map(MarkdownBlock::toString) }.toTypedArray(), + ) + } +}