IJPL-246382 [markdown] use highlighter from the editor when calculating wrap position

(cherry picked from commit d1ff4df1b0bfeb9c8fdce3a32723f4f7475c0852)

GitOrigin-RevId: 51ef13c7c908558f196c9559480af92d5dfdabec
This commit is contained in:
Maxime Kudrinski
2026-08-14 12:12:35 +00:00
committed by intellij-monorepo-bot
parent d589bf6fe0
commit ea78481233
17 changed files with 199 additions and 165 deletions
+2
View File
@@ -1720,7 +1720,9 @@ com.intellij.openapi.editor.LineNumberConverter$Increasing
- getMaxLineNumber(com.intellij.openapi.editor.Editor):java.lang.Integer
com.intellij.openapi.editor.LineWrapPositionStrategy
- a:calculateWrapPosition(com.intellij.openapi.editor.Document,com.intellij.openapi.project.Project,I,I,I,Z,Z):I
- calculateWrapPosition(com.intellij.openapi.editor.Editor,I,I,I,Z,Z):I
- canWrapLineAtOffset(java.lang.CharSequence,I):Z
- getWrapDocument(com.intellij.openapi.editor.Editor,Z):com.intellij.openapi.editor.Document
c:com.intellij.openapi.editor.LogicalPosition
- java.lang.Comparable
- f:column:I
@@ -29,7 +29,7 @@ import org.jetbrains.annotations.Nullable;
public interface LineWrapPositionStrategy {
/**
* Allows to calculate the most appropriate position to wrap target line.
* Allows calculating the most appropriate position to wrap target line.
*
* @param document target document which text is being processed
* @param project target project
@@ -51,6 +51,49 @@ public interface LineWrapPositionStrategy {
boolean allowToBeyondMaxPreferredOffset, boolean isSoftWrap
);
/**
* Serves the same purposes as {@link #calculateWrapPosition(Document, Project, int, int, int, boolean, boolean)} but is given the
* editor that shows the target text. Implement this method instead of the one above if the wrap position depends on the editor
* context, e.g. on its {@link Editor#getHighlighter() highlighter} or settings.
* <p/>
* The document which text is being processed is derived from the given editor, see {@link #getWrapDocument(Editor, boolean)}.
*
* @param editor target editor which text is being processed
* @param startOffset start offset to use with the given text holder (exclusive)
* @param endOffset end offset to use with the given text holder (exclusive)
* @param maxPreferredOffset this method is expected to do its best to return offset that belongs to
* {@code (startOffset; maxPreferredOffset]} interval. However, it's allowed
* to return value from {@code (maxPreferredOffset; endOffset)} interval
* unless {@code 'allowToBeyondMaxPreferredOffset'} if {@code 'false'}
* @param allowToBeyondMaxPreferredOffset indicates if it's allowed to return value from
* {@code (maxPreferredOffset; endOffset]} interval in case of inability to
* find appropriate offset from {@code (startOffset; maxPreferredOffset]} interval
* @param isSoftWrap identifies if current request is for isSoftWrap wrap position
* @return offset from {@code (startOffset; endOffset)} interval where
* target line should be wrapped OR {@code -1} if no wrapping should be performed
*/
default int calculateWrapPosition(
@NotNull Editor editor, int startOffset, int endOffset, int maxPreferredOffset,
boolean allowToBeyondMaxPreferredOffset, boolean isSoftWrap
) {
return calculateWrapPosition(getWrapDocument(editor, isSoftWrap), editor.getProject(), startOffset, endOffset, maxPreferredOffset,
allowToBeyondMaxPreferredOffset, isSoftWrap);
}
/**
* Allows choosing the document that holds the text to wrap for the given editor.
* <p/>
* Soft wrapping is a UI-side layout activity, so it observes the document that the editor currently displays. Hard wrapping modifies
* the text instead, so it has to work with the document that is authoritative for PSI, undo and persistence.
*
* @param editor target editor which text is being processed
* @param isSoftWrap identifies if current request is for isSoftWrap wrap position
* @return document to calculate wrap positions against for the given editor
*/
default @NotNull Document getWrapDocument(@NotNull Editor editor, boolean isSoftWrap) {
return isSoftWrap ? editor.getElfDocument() : editor.getDocument();
}
/**
*The method ensures:
* - No breaks within surrogate pairs to prevent visual errors.
@@ -152,7 +152,7 @@ public class AutoHardWrapHandler {
Math.max(0, margin - FormatConstants.getReservedLineWrapWidthInColumns(editor)))
);
int wrapOffset = strategy.calculateWrapPosition(document, project, startOffset, endOffset, maxPreferredOffset, true, false);
int wrapOffset = strategy.calculateWrapPosition(editor, startOffset, endOffset, maxPreferredOffset, true, false);
if (wrapOffset < 0) {
return;
}
@@ -137,7 +137,7 @@ public final class LineWrappingUtil {
// We know that current line exceeds right margin if control flow reaches this place, so, wrap it.
int wrapOffset = strategy.calculateWrapPosition(
document, project, Math.max(startLineOffset, startOffsetToUse), Math.min(endLineOffset, endOffsetToUse),
editor, Math.max(startLineOffset, startOffsetToUse), Math.min(endLineOffset, endOffsetToUse),
preferredWrapPosition, false, false
);
if (wrapOffset < 0 // No appropriate wrap position is found.
@@ -204,9 +204,8 @@ public final class SoftWrapEngine {
if (position != -1) return position;
}
int wrapOffset = myLineWrapPositionStrategy.calculateWrapPosition(myDocument, myEditor.getProject(),
minOffset - 1, maxOffset + 1, maxOffset + 1,
false, true);
int wrapOffset = myLineWrapPositionStrategy.calculateWrapPosition(myEditor, minOffset - 1, maxOffset + 1,
maxOffset + 1, false, true);
if (wrapOffset < 0) return preferMinOffset ? minOffset : maxOffset;
if (wrapOffset < minOffset) return minOffset;
if (wrapOffset > maxOffset) return maxOffset;
@@ -2,14 +2,10 @@
package org.intellij.plugins.markdown.editor
import com.intellij.openapi.editor.Document
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.GenericLineWrapPositionStrategy
import com.intellij.openapi.editor.colors.impl.EmptyColorScheme
import com.intellij.openapi.editor.ex.util.LexerEditorHighlighter
import com.intellij.openapi.editor.highlighter.HighlighterIterator
import com.intellij.openapi.project.Project
import com.intellij.psi.tree.IElementType
import org.intellij.plugins.markdown.editor.MarkdownLineWrapPositionStrategy.Companion.MAX_LEXING_CONTEXT_LENGTH
import org.intellij.plugins.markdown.highlighting.MarkdownSyntaxHighlighter
import org.intellij.plugins.markdown.lang.MarkdownElementTypes
import org.intellij.plugins.markdown.lang.MarkdownTokenTypes
class MarkdownLineWrapPositionStrategy : GenericLineWrapPositionStrategy() {
@@ -36,171 +32,100 @@ class MarkdownLineWrapPositionStrategy : GenericLineWrapPositionStrategy() {
addRule(Rule('{', WrapCondition.BEFORE))
}
override fun calculateWrapPosition(document: Document, project: Project?, startOffset: Int, endOffset: Int, maxPreferredOffset: Int,
allowToBeyondMaxPreferredOffset: Boolean, isSoftWrap: Boolean): Int {
val position = super.calculateWrapPosition(document, project, startOffset, endOffset, maxPreferredOffset,
override fun calculateWrapPosition(editor: Editor, startOffset: Int, endOffset: Int,
maxPreferredOffset: Int, allowToBeyondMaxPreferredOffset: Boolean,
isSoftWrap: Boolean): Int {
val document = getWrapDocument(editor, isSoftWrap)
val position = super.calculateWrapPosition(document, editor.project, startOffset, endOffset, maxPreferredOffset,
allowToBeyondMaxPreferredOffset, isSoftWrap)
if (position < 0) return position
// Soft wrapping calls this method for every wrapped visual line, so the amount of text lexed per call must stay
// bounded, or large documents freeze the editor. The token checks below are line-scoped ([findOnLine] stops at EOL),
// and the Markdown constructs they detect (headers, tables, inline links) do not span blank lines, so lexing the
// enclosing blank-line-delimited block is enough. All offsets inside the checks are relative to [contextStart].
// See IJPL-250302 for details.
val text = document.immutableCharSequence
val contextStart = findContextStart(text, position)
val contextEnd = findContextEnd(text, position)
val highlighter = LexerEditorHighlighter(MarkdownSyntaxHighlighter(), EmptyColorScheme.getEmptyScheme())
highlighter.setText(text.subSequence(contextStart, contextEnd))
val decision = classifyForbidden(highlighter, position - contextStart) ?: return position
if (decision == ForbiddenDecision.NoWrap) return -1
val forbiddenStart = contextStart + (decision as ForbiddenDecision.WrapBefore).offset
if (forbiddenStart <= startOffset) return -1
val retry = super.calculateWrapPosition(document, project, startOffset, forbiddenStart,
val highlighter = editor.highlighter as? LexerEditorHighlighter ?: return if (isSoftWrap) position else -1
val forbiddenStart = classifyForbidden(highlighter, document, position) ?: return position
if (forbiddenStart == NEVER_WRAP || forbiddenStart <= startOffset) return -1
val retry = super.calculateWrapPosition(document, editor.project, startOffset, forbiddenStart,
minOf(maxPreferredOffset, forbiddenStart),
allowToBeyondMaxPreferredOffset, isSoftWrap)
return if (retry > 0) retry else forbiddenStart
}
/**
* Returns the offset of the first character of the blank-line-delimited block containing [position],
* looking back at most [MAX_LEXING_CONTEXT_LENGTH] characters. For a line longer than the limit the block is cut
* mid-line; the checks then degrade to "no forbidden construct found", which only affects the wrap position choice.
* Returns the offset of the construct the wrap has to be moved in front of, [NEVER_WRAP] when no position on this
* line is acceptable, or null when wrapping at [position] is fine.
*/
private fun findContextStart(text: CharSequence, position: Int): Int {
val lowerBound = (position - MAX_LEXING_CONTEXT_LENGTH).coerceAtLeast(0)
var blockStart = lowerBound
var lineIsBlank = true
for (offset in lowerBound until position) {
when (text[offset]) {
'\n' -> {
if (lineIsBlank) blockStart = offset + 1
lineIsBlank = true
}
' ', '\t' -> {}
else -> lineIsBlank = false
}
}
return blockStart
}
private fun classifyForbidden(highlighter: LexerEditorHighlighter, document: Document, position: Int): Int? {
val iterator = highlighter.createIterator(position)
if (iterator.atEnd()) return null
val type = iterator.tokenType ?: return null
/**
* Returns the end offset (exclusive) of the blank-line-delimited block containing,
* looking ahead at most [MAX_LEXING_CONTEXT_LENGTH] characters.
*/
private fun findContextEnd(text: CharSequence, position: Int): Int {
val upperBound = (position + MAX_LEXING_CONTEXT_LENGTH).coerceAtMost(text.length)
var lineIsBlank = false // the line containing the wrap candidate is never blank
for (offset in position until upperBound) {
when (text[offset]) {
'\n' -> {
if (lineIsBlank) return offset
lineIsBlank = true
}
' ', '\t' -> {}
else -> lineIsBlank = false
}
}
return upperBound
}
// A table row is one CELL token per cell, links inside included, so every position in it is inside the table.
if (type in TABLE_TOKENS) return NEVER_WRAP
private sealed interface ForbiddenDecision {
/** Wrapping at the candidate position is forbidden and there is no acceptable retry — return -1. */
object NoWrap : ForbiddenDecision
/** Wrapping at the candidate position is forbidden; retry with end clamped to [offset] (the construct's start). */
data class WrapBefore(val offset: Int) : ForbiddenDecision
}
val line = document.getLineNumber(position)
if (isSetextHeaderContent(highlighter, document, line)) return NEVER_WRAP
private fun classifyForbidden(highlighter: LexerEditorHighlighter, position: Int): ForbiddenDecision? {
val iter = highlighter.createIterator(position)
if (iter.atEnd()) return null
val type = iter.tokenType ?: return null
// Headers and tables: leaf tokens at [position] are usually TEXT/WHITE_SPACE inside the construct, so
// walk the current line for the construct-defining markers.
if (isInsideHeader(highlighter, position) || isInsideTableRow(highlighter, position)) return ForbiddenDecision.NoWrap
// Inside a link destination / autolink / link title: clear-cut, walk back to the LBRACKET that opens the link.
if (type in FORBIDDEN_INSIDE_LINK) {
val linkStart = findOnLine(highlighter, position, Direction.BACKWARD) { it == MarkdownTokenTypes.LBRACKET }
return ForbiddenDecision.WrapBefore(linkStart ?: iter.start)
// The ATX marker and the `[` that opens a link are both found by one walk back over the line. The marker has to be
// looked for whatever the token at [position] is, since the content of a header is plain text.
var linkStart = NOT_FOUND
val lineStart = maxOf(document.getLineStartOffset(line), position - MAX_SCAN_DISTANCE)
val tokens = highlighter.createIterator(position)
while (!tokens.atEnd() && tokens.start >= lineStart) {
val walked = tokens.tokenType
if (walked == MarkdownTokenTypes.ATX_HEADER) return NEVER_WRAP
if (walked == MarkdownTokenTypes.LBRACKET && linkStart == NOT_FOUND) linkStart = tokens.start
if (tokens.start == 0) break
tokens.retreat()
}
// Ambiguous brackets/parens: only forbidden when part of a `[…](…)` sequence on this line.
// Boundary: wrap exactly at the opening `[` (iter.start == position) is allowed — we wrap BEFORE the link.
if (type == MarkdownTokenTypes.LBRACKET && iter.start == position) return null
if (type in LINK_BRACKETS) {
if (!looksLikeInlineLink(highlighter, position)) return null
val linkStart = findOnLine(highlighter, position, Direction.BACKWARD) { it == MarkdownTokenTypes.LBRACKET } ?: return null
return ForbiddenDecision.WrapBefore(linkStart)
// Inside a destination, an autolink or a title the wrap belongs in front of the whole link.
if (type in FORBIDDEN_INSIDE_LINK) return if (linkStart == NOT_FOUND) iterator.start else linkStart
// Ambiguous brackets/parens: forbidden only as part of a `[…](…)` sequence, and a wrap exactly at the opening
// `[` is allowed, since that already wraps in front of the link.
if (type !in LINK_BRACKETS || linkStart == NOT_FOUND ||
(type == MarkdownTokenTypes.LBRACKET && iterator.start == position)) {
return null
}
return null
return if (hasClosingParen(highlighter, position, document.getLineEndOffset(line))) linkStart else null
}
private fun isInsideHeader(highlighter: LexerEditorHighlighter, position: Int): Boolean =
findOnLine(highlighter, position, Direction.BACKWARD) { it in HEADER_TOKENS } != null
private fun isInsideTableRow(highlighter: LexerEditorHighlighter, position: Int): Boolean {
val isSeparator: (IElementType) -> Boolean = { it == MarkdownTokenTypes.TABLE_SEPARATOR }
return findOnLine(highlighter, position, Direction.BACKWARD, isSeparator) != null
|| findOnLine(highlighter, position, Direction.FORWARD, isSeparator) != null
private fun isSetextHeaderContent(highlighter: LexerEditorHighlighter, document: Document, line: Int): Boolean {
if (line + 1 >= document.lineCount) return false
val iterator = highlighter.createIterator(document.getLineStartOffset(line + 1))
return !iterator.atEnd() && iterator.tokenType in SETEXT_UNDERLINES
}
private fun looksLikeInlineLink(highlighter: LexerEditorHighlighter, position: Int): Boolean =
findOnLine(highlighter, position, Direction.BACKWARD) { it == MarkdownTokenTypes.LBRACKET } != null &&
findOnLine(highlighter, position, Direction.FORWARD) { it == MarkdownTokenTypes.RPAREN } != null
/**
* Walks the highlighter's token stream from [position] in [direction], stopping at EOL or the buffer ends.
* Returns the start offset of the first token whose type matches [match], or null if none is found before
* the line/buffer boundary.
*/
private inline fun findOnLine(
highlighter: LexerEditorHighlighter,
position: Int,
direction: Direction,
match: (IElementType) -> Boolean,
): Int? {
val it: HighlighterIterator = highlighter.createIterator(position)
while (!it.atEnd()) {
val t = it.tokenType
if (t == MarkdownTokenTypes.EOL) return null
if (t != null && match(t)) return it.start
when (direction) {
Direction.BACKWARD -> {
if (it.start == 0) return null
it.retreat()
}
Direction.FORWARD -> it.advance()
}
private fun hasClosingParen(highlighter: LexerEditorHighlighter, position: Int, lineEnd: Int): Boolean {
val end = minOf(lineEnd, position + MAX_SCAN_DISTANCE)
val tokens = highlighter.createIterator(position)
while (!tokens.atEnd() && tokens.start < end) {
if (tokens.tokenType == MarkdownTokenTypes.RPAREN) return true
tokens.advance()
}
return null
}
private enum class Direction { BACKWARD, FORWARD }
companion object {
private const val MAX_LEXING_CONTEXT_LENGTH = 4096
private val FORBIDDEN_INSIDE_LINK = setOf(
MarkdownTokenTypes.URL,
MarkdownTokenTypes.AUTOLINK,
MarkdownTokenTypes.GFM_AUTOLINK,
MarkdownTokenTypes.EMAIL_AUTOLINK,
MarkdownTokenTypes.LINK_TITLE,
)
private val LINK_BRACKETS = setOf(
MarkdownTokenTypes.LBRACKET,
MarkdownTokenTypes.RBRACKET,
MarkdownTokenTypes.LPAREN,
MarkdownTokenTypes.RPAREN,
)
private val HEADER_TOKENS = setOf(
MarkdownTokenTypes.ATX_HEADER,
MarkdownTokenTypes.ATX_CONTENT,
MarkdownTokenTypes.SETEXT_1,
MarkdownTokenTypes.SETEXT_2,
MarkdownTokenTypes.SETEXT_CONTENT,
)
return false
}
}
private const val NEVER_WRAP = -1
private const val NOT_FOUND = -1
private const val MAX_SCAN_DISTANCE = 4096
private val TABLE_TOKENS = setOf(MarkdownElementTypes.TABLE_CELL, MarkdownTokenTypes.TABLE_SEPARATOR)
private val FORBIDDEN_INSIDE_LINK = setOf(
MarkdownTokenTypes.URL,
MarkdownTokenTypes.AUTOLINK,
MarkdownTokenTypes.GFM_AUTOLINK,
MarkdownTokenTypes.EMAIL_AUTOLINK,
MarkdownTokenTypes.LINK_TITLE,
)
private val LINK_BRACKETS = setOf(
MarkdownTokenTypes.LBRACKET,
MarkdownTokenTypes.RBRACKET,
MarkdownTokenTypes.LPAREN,
MarkdownTokenTypes.RPAREN,
)
private val SETEXT_UNDERLINES = setOf(
MarkdownTokenTypes.SETEXT_1,
MarkdownTokenTypes.SETEXT_2,
)
@@ -0,0 +1,2 @@
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa<caret>
https://openziti.github.io/ziti/overview/details.html
@@ -0,0 +1 @@
aaaaaaaaaaaaaaaaaaaaaaaaaaaaa<caret> https://openziti.github.io/ziti/overview/details.html
@@ -0,0 +1,4 @@
```
# Gallia est omnis divisa in partes tres, quarum unam incolumnt Belgae,
aliam a b c<caret>
```
@@ -0,0 +1,3 @@
```
# Gallia est omnis divisa in partes tres, quarum unam incolumnt Belgae, aliam <caret>
```
@@ -0,0 +1,2 @@
baz asd as das synchronization<caret>das da sd as das d as d as d as d as d as d as d as d as da sd as d as d as d as d as d as da sd
===
@@ -0,0 +1,2 @@
baz asd as das <caret>das da sd as das d as d as d as d as d as d as d as d as da sd as d as d as d as d as d as da sd
===
@@ -0,0 +1,2 @@
Gallia est omnis divisa in partes tres, quarum unam | incolumnt Belgae,
aliam a b c<caret>
@@ -0,0 +1 @@
Gallia est omnis divisa in partes tres, quarum unam | incolumnt Belgae, aliam <caret>
@@ -26,22 +26,47 @@ class MarkdownSoftWrapPerformanceTest : BasePlatformTestCase() {
}.runAsStressTest().start()
}
fun `test soft wrap recalculation in a large html block`() {
myFixture.configureByText("htmlBlock.md", generateHtmlBlockDocument())
val editor = myFixture.editor
EditorTestUtil.configureSoftWraps(editor, 120)
val softWrapModel = editor.softWrapModel as SoftWrapModelImpl
Benchmark.newBenchmark("full soft wrap recalculation in a large html block") {
softWrapModel.applianceManager.reset()
softWrapModel.applianceManager.recalculateIfNecessary("MarkdownSoftWrapPerformanceTest")
assertNotEmpty(softWrapModel.registeredSoftWraps)
}.runAsStressTest().start()
}
private fun generateHtmlBlockDocument(): String {
return buildString {
append("<div class=\"wrapper\">\n")
var wordIndex = 0
repeat(2000) {
append(" <p>")
repeat(12) {
append(LONG_WORDS[wordIndex % LONG_WORDS.size])
append(' ')
wordIndex++
}
append("</p>\n")
}
append("</div>\n")
}
}
/**
* ~1.4 MB of markdown built from long words, so that many soft wrap positions have no whitespace within
* the quick look-back distance of [com.intellij.openapi.editor.impl.SoftWrapEngine] and the position
* calculation is delegated to [org.intellij.plugins.markdown.editor.MarkdownLineWrapPositionStrategy].
*/
private fun generateLargeDocument(): String {
val words = listOf(
"internationalization", "misconfiguration", "straightforwardness", "characterization", "acknowledgements",
"responsibilities", "incompatibilities", "synchronization", "parallelization", "representations",
)
return buildString {
var wordIndex = 0
repeat(600) { paragraphIndex ->
repeat(8) { lineIndex ->
repeat(14) {
append(words[wordIndex % words.size])
append(LONG_WORDS[wordIndex % LONG_WORDS.size])
append(' ')
wordIndex++
}
@@ -52,3 +77,8 @@ class MarkdownSoftWrapPerformanceTest : BasePlatformTestCase() {
}
}
}
private val LONG_WORDS = listOf(
"internationalization", "misconfiguration", "straightforwardness", "characterization", "acknowledgements",
"responsibilities", "incompatibilities", "synchronization", "parallelization", "representations",
)
@@ -8,8 +8,8 @@ class MarkdownSoftWrapTest : BasePlatformTestCase() {
/**
* The wrap candidate falls inside the link, where wrapping is forbidden, so the strategy has to move
* the soft wrap before the link start. The link is placed after several blank-line-separated paragraphs to check
* that [org.intellij.plugins.markdown.editor.MarkdownLineWrapPositionStrategy] resolves tokens correctly when
* the lexed context does not start at the beginning of the document.
* that [com.intellij.markdown.frontend.editor.MarkdownLineWrapPositionStrategy] resolves offsets correctly when
* the line under test does not start at the beginning of the document.
*/
fun `test soft wrap does not break a link located after the first block`() {
val filler = "Introductory paragraph that occupies space before the block under test.\n\n".repeat(3)
@@ -63,6 +63,11 @@ public class MarkdownWrapTest extends BasePlatformTestCase {
doTest("a b c");
}
/** Fence content is literal, so a `#` at the start of a fenced line is not a header and the line is wrappable. */
public void testWrapInCodeFenceWithHash() {
doTest("a b c");
}
public void testWrapInCodeFenceInQuotes() {
doTest("a b c d e f");
}
@@ -75,15 +80,28 @@ public class MarkdownWrapTest extends BasePlatformTestCase {
doTest("synchronization");
}
public void testWrapInSetextHeader() {
doTest("synchronization");
}
public void testWrapInTable() {
doTest("synchronization");
}
/** A `|` outside of a table does not make the line a table row, so wrapping is still allowed. */
public void testWrapWithPipeInText() {
doTest("a b c");
}
public void testWrapDoesNotBreakLink() {
doTest("a");
}
public void testWrapDoesNotBreakBareUrl() {
doTest("a");
}
public void testWrapDoesNotBreakLinkWhenTypingInsideLink() {
doTest("X");
}