diff --git a/platform/editor-ui-api/api-dump.txt b/platform/editor-ui-api/api-dump.txt index c6371568e603..a5766320197c 100644 --- a/platform/editor-ui-api/api-dump.txt +++ b/platform/editor-ui-api/api-dump.txt @@ -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 diff --git a/platform/editor-ui-api/src/com/intellij/openapi/editor/LineWrapPositionStrategy.java b/platform/editor-ui-api/src/com/intellij/openapi/editor/LineWrapPositionStrategy.java index 3b0965242e2a..57c951d88c81 100644 --- a/platform/editor-ui-api/src/com/intellij/openapi/editor/LineWrapPositionStrategy.java +++ b/platform/editor-ui-api/src/com/intellij/openapi/editor/LineWrapPositionStrategy.java @@ -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. + *
+ * 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. + * + * 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. diff --git a/platform/lang-impl/src/com/intellij/codeInsight/editorActions/AutoHardWrapHandler.java b/platform/lang-impl/src/com/intellij/codeInsight/editorActions/AutoHardWrapHandler.java index 67b803ea6909..5f45e66cef02 100644 --- a/platform/lang-impl/src/com/intellij/codeInsight/editorActions/AutoHardWrapHandler.java +++ b/platform/lang-impl/src/com/intellij/codeInsight/editorActions/AutoHardWrapHandler.java @@ -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; } diff --git a/platform/platform-impl/src/com/intellij/formatting/LineWrappingUtil.java b/platform/platform-impl/src/com/intellij/formatting/LineWrappingUtil.java index d744b9717864..d6ba95c96778 100644 --- a/platform/platform-impl/src/com/intellij/formatting/LineWrappingUtil.java +++ b/platform/platform-impl/src/com/intellij/formatting/LineWrappingUtil.java @@ -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. diff --git a/platform/platform-impl/src/com/intellij/openapi/editor/impl/SoftWrapEngine.java b/platform/platform-impl/src/com/intellij/openapi/editor/impl/SoftWrapEngine.java index baef60eabb8a..10b3c8dfa0c7 100644 --- a/platform/platform-impl/src/com/intellij/openapi/editor/impl/SoftWrapEngine.java +++ b/platform/platform-impl/src/com/intellij/openapi/editor/impl/SoftWrapEngine.java @@ -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; diff --git a/plugins/markdown/core/src/org/intellij/plugins/markdown/editor/MarkdownLineWrapPositionStrategy.kt b/plugins/markdown/core/src/org/intellij/plugins/markdown/editor/MarkdownLineWrapPositionStrategy.kt index 78e47518ff0b..0e102d22036a 100644 --- a/plugins/markdown/core/src/org/intellij/plugins/markdown/editor/MarkdownLineWrapPositionStrategy.kt +++ b/plugins/markdown/core/src/org/intellij/plugins/markdown/editor/MarkdownLineWrapPositionStrategy.kt @@ -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, +) diff --git a/plugins/markdown/test/data/wrap/wrapDoesNotBreakBareUrl.after.md b/plugins/markdown/test/data/wrap/wrapDoesNotBreakBareUrl.after.md new file mode 100644 index 000000000000..5000278e64da --- /dev/null +++ b/plugins/markdown/test/data/wrap/wrapDoesNotBreakBareUrl.after.md @@ -0,0 +1,2 @@ +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + repeat(12) { + append(LONG_WORDS[wordIndex % LONG_WORDS.size]) + append(' ') + wordIndex++ + } + append("
\n") + } + append("