Skip to content

Commit

Permalink
[JEWEL-600] Use chars indexes for markdown edits
Browse files Browse the repository at this point in the history
Our previouis version was marking a previouis
and the following blocks as modified, while it's
not needed in most cases.

We can use use 0.24.0 inputIndex parameter to
simply keep an index from char to block and avoid
splitting lines and doing complicated search for
a modified substring.
  • Loading branch information
Oleg Baskakov committed Jan 31, 2025
1 parent e1e56e0 commit 1a63b71
Show file tree
Hide file tree
Showing 4 changed files with 367 additions and 180 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,14 @@ public class MarkdownProcessor(
private val commonMarkParser: Parser =
MarkdownParserFactory.create(markdownMode is MarkdownMode.EditorPreview, extensions),
) {
private var currentState = State(emptyList(), emptyList(), emptyList())
private var currentState = State("", emptyList())

@TestOnly internal fun getCurrentIndexesInTest() = currentState.indexes
@TestOnly
internal fun getCurrentIndexesInTest() = buildList {
for (block in currentState.blocks) {
block.traverseAll { node -> add(node.sourceSpans) }
}
}

private val scrollingSynchronizer: ScrollingSynchronizer? =
(markdownMode as? MarkdownMode.EditorPreview)?.scrollingSynchronizer
Expand Down Expand Up @@ -96,134 +101,69 @@ public class MarkdownProcessor(

@VisibleForTesting
internal fun processWithQuickEdits(@Language("Markdown") rawMarkdown: String): List<Block> {
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 (j < 0 || j >= newLines.size || 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) {
val newIndex = j + nLinesDelta
if (newIndex < 0 || newIndex >= newLines.size || previousLines[j] != newLines[newIndex]) {
break@outerLoop
}
}
lastBlock = currLastBlock
lastLine = currLastLine
currLastBlock = i
currLastLine = begin
val (previousText, previousBlocks) = currentState
if (previousText == rawMarkdown) return previousBlocks
// make sure we have at least one element
if (previousBlocks.isEmpty()) {
val newBlocks = parseRawMarkdown(rawMarkdown)
currentState = State(rawMarkdown, newBlocks)
return newBlocks
}

if (firstLine > lastLine + nLinesDelta) {
// no change
return previousBlocks
}

val updatedText = newLines.subList(firstLine, lastLine + nLinesDelta).joinToString("\n", postfix = "\n")
val updatedBlocks: List<Block> = parseRawMarkdown(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
}

val firstLineIndex = node.sourceSpans.first().lineIndex + firstLine
val lastLineIndex = node.sourceSpans.last().lineIndex + firstLine

firstLineIndex to lastLineIndex
}

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 newIndexes = previousIndexes.subList(0, firstBlock) + updatedIndexes + suffixIndexes

val nCharsDelta = rawMarkdown.length - previousText.length
val nLinesDelta = rawMarkdown.lineSequence().count() - previousText.lineSequence().count()
val commonPrefix = previousText.commonPrefixWith(rawMarkdown)
val prefixPos = commonPrefix.length
// remove prefixes to avoid overlap
val commonSuffix =
previousText.removePrefix(commonPrefix).commonSuffixWith(rawMarkdown.removePrefix(commonPrefix))
val suffixPos = previousText.length - commonSuffix.length
val previousIndexes = previousBlocks.map { block -> block.sourceSpans.first().inputIndex }
// if modification starts at the edge, include previous by using less instead of less equal
val firstBlock = previousIndexes.indexOfLast { it < prefixPos }.takeIf { it != -1 } ?: 0
val blockAfterLast = previousIndexes.indexOfFirst { it > suffixPos }
val updatedText =
rawMarkdown.substring(
previousIndexes[firstBlock],
if (blockAfterLast == -1) rawMarkdown.length else previousIndexes[blockAfterLast] - 1 + nCharsDelta,
)
val updatedBlocks = parseRawMarkdown(updatedText)
val firstBlockOffset = previousBlocks[firstBlock].sourceSpans.first()
// Processor only re-parses the changed part of the document, which has two outcomes:
// 1. sourceSpans in updatedBlocks start from line index 0, not from the actual line
// the update part starts in the document;
// 2. sourceSpans in blocks after the changed part remain unchanged
// (therefore irrelevant too).
//
// Addressing the second outcome is easy, as all the lines there were just shifted by
// nLinesDelta.

for (i in lastBlock until newBlocks.size) {
newBlocks[i].traverseAll { node ->
for (block in updatedBlocks) {
block.traverseAll { node ->
node.sourceSpans =
node.sourceSpans.map { span ->
SourceSpan.of(span.lineIndex + nLinesDelta, span.columnIndex, span.inputIndex, span.length)
SourceSpan.of(
/* line = */ span.lineIndex + firstBlockOffset.lineIndex,
/* col = */ span.columnIndex,
/* input = */ span.inputIndex + firstBlockOffset.inputIndex,
/* length = */ span.length,
)
}
}
}

// The first outcome is a bit trickier. Consider a fresh new block with the following
// structure:
//
// indexes spans
// Block A [10-20] (0-10)
// block A1 [ n/a ] (0-2)
// block A2 [ n/a ] (3-10)
// Block B [21-30] (11-20)
// block B1 [ n/a ] (11-16)
// block B2 [ n/a ] (17-20)
//
// There are two updated blocks with two children each.
// Note that at this point the indexes are updated, yet they only exist for the topmost
// blocks.
// So, to calculate actual spans for, for example, block B2 (B2s), we need to also take into
// account
// the first index of the block B (Bi) and the first span of the block B (Bs) and use the
// formula
// B2s = (B2s - Bs) + Bi
for ((block, indexes) in updatedBlocks.zip(updatedIndexes)) {
val firstSpanLineIndex = block.sourceSpans.firstOrNull()?.lineIndex ?: continue
val firstIndex = indexes.first
val suffixBlocks =
if (blockAfterLast == -1) emptyList() else previousBlocks.subList(blockAfterLast, previousBlocks.size)
for (block in suffixBlocks) {
block.traverseAll { node ->
node.sourceSpans =
node.sourceSpans.map { span ->
SourceSpan.of(
span.lineIndex - firstSpanLineIndex + firstIndex,
span.columnIndex,
span.inputIndex,
span.length,
/* line = */ span.lineIndex + nLinesDelta,
/* col = */ span.columnIndex,
/* input = */ span.inputIndex + nCharsDelta,
/* length = */ span.length,
)
}
}
}

currentState = State(newLines, newBlocks, newIndexes)
val newBlocks = previousBlocks.subList(0, firstBlock) + updatedBlocks + suffixBlocks
currentState = State(rawMarkdown, newBlocks)

return newBlocks
}
Expand Down Expand Up @@ -347,5 +287,6 @@ public class MarkdownProcessor(

private fun Block.readInlineContent() = readInlineContent(this@MarkdownProcessor, extensions)

private data class State(val lines: List<String>, val blocks: List<Block>, val indexes: List<Pair<Int, Int>>)
/** Store parsed blocks and first char indexes for each block */
private data class State(val text: String, val blocks: List<Block>)
}
Loading

0 comments on commit 1a63b71

Please sign in to comment.