IJPL-93440 Markdown: Create link from selection: do not create link in code snippets etc

GitOrigin-RevId: 4cf115e6079fdd241167ebbd1677cd8c56d5bdc9
This commit is contained in:
Anna Solomennik
2026-08-13 07:10:31 +00:00
committed by intellij-monorepo-bot
parent 7f0322abb8
commit 1b8403e5b9
5 changed files with 157 additions and 3 deletions
@@ -5,6 +5,9 @@ import com.intellij.lang.Language
import com.intellij.notebooks.jupyter.core.jupyter.CellType
import com.intellij.openapi.actionSystem.CommonDataKeys
import com.intellij.openapi.actionSystem.DataContext
import com.intellij.openapi.util.TextRange
import com.intellij.psi.PsiDocumentManager
import com.intellij.psi.PsiFile
import org.intellij.plugins.markdown.lang.MarkdownCompatibilityChecker
class JupyterMarkdownCompatibilityChecker : MarkdownCompatibilityChecker {
@@ -18,4 +21,15 @@ class JupyterMarkdownCompatibilityChecker : MarkdownCompatibilityChecker {
val cell = NotebookCellLines.get(editor).getCellByLineNumber(lineNumber) ?: return true
return cell.type == CellType.MARKDOWN
}
override fun isSupportedRange(file: PsiFile, range: TextRange): Boolean {
if (!isSupportedLanguage(file.language) || range.isEmpty) return false
val document = PsiDocumentManager.getInstance(file.project).getDocument(file) ?: return false
val startLine = document.getLineNumber(range.startOffset)
val endLine = document.getLineNumber(range.endOffset - 1)
val cellLines = NotebookCellLines.get(document)
val startCell = cellLines.getCellByLineNumber(startLine) ?: return false
val endCell = cellLines.getCellByLineNumber(endLine) ?: return false
return startCell == endCell && startCell.type == CellType.MARKDOWN
}
}
@@ -5,7 +5,13 @@ import com.intellij.codeInsight.editorActions.CopyPastePreProcessor
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.RawText
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.TextRange
import com.intellij.psi.PsiFile
import com.intellij.psi.util.elementType
import com.intellij.psi.util.parents
import org.intellij.plugins.markdown.lang.MarkdownElementTypes
import org.intellij.plugins.markdown.lang.MarkdownLanguage
import org.intellij.plugins.markdown.lang.MarkdownTokenTypes
import org.intellij.plugins.markdown.lang.supportsMarkdown
internal class MarkdownLinkPastePreProcessor : CopyPastePreProcessor {
@@ -16,7 +22,8 @@ internal class MarkdownLinkPastePreProcessor : CopyPastePreProcessor {
override fun preprocessOnPaste(project: Project, file: PsiFile, editor: Editor, text: String, rawText: RawText?): String {
val linkText = editor.selectionModel.selectedText ?: return text
val linkDestination = MarkdownLinkEditingUtil.getLinkDestination(text) ?: return text
if (linkText.isBlank() || !file.language.supportsMarkdown()) {
val selectionRange = TextRange(editor.selectionModel.selectionStart, editor.selectionModel.selectionEnd)
if (linkText.isBlank() || !file.supportsMarkdown(selectionRange) || isInIgnoredContext(file, selectionRange)) {
return text
}
return MarkdownLinkEditingUtil.createInlineLink(linkText, linkDestination)
@@ -26,7 +33,31 @@ internal class MarkdownLinkPastePreProcessor : CopyPastePreProcessor {
return false
}
override fun requiresAllDocumentsToBeCommitted(editor: Editor, project: Project): Boolean {
return false
private fun isInIgnoredContext(file: PsiFile, selectionRange: TextRange): Boolean {
val contextFile = file.viewProvider.getPsi(MarkdownLanguage.INSTANCE) ?: file
val startElement = contextFile.findElementAt(selectionRange.startOffset) ?: return false
val endElement = contextFile.findElementAt(selectionRange.endOffset - 1) ?: return false
return sequenceOf(startElement, endElement).any { element ->
// Markdown PSI can return an embedded HTML element at the requested offset.
!element.supportsMarkdown() || element.parents(withSelf = true).any { it.elementType in ignoredLinkPasteContextTypes }
}
}
}
private val ignoredLinkPasteContextTypes = setOf(
MarkdownElementTypes.CODE_FENCE,
MarkdownElementTypes.CODE_BLOCK,
MarkdownElementTypes.CODE_SPAN,
MarkdownElementTypes.HTML_BLOCK,
MarkdownElementTypes.LINK_TEXT,
MarkdownElementTypes.LINK_DESTINATION,
MarkdownElementTypes.LINK_DEFINITION,
MarkdownElementTypes.INLINE_LINK,
MarkdownElementTypes.FULL_REFERENCE_LINK,
MarkdownElementTypes.SHORT_REFERENCE_LINK,
MarkdownElementTypes.IMAGE,
MarkdownElementTypes.TEST_LINK,
MarkdownElementTypes.AUTOLINK,
MarkdownElementTypes.FRONT_MATTER_HEADER,
MarkdownTokenTypes.GFM_AUTOLINK,
)
@@ -4,6 +4,9 @@ package org.intellij.plugins.markdown.lang
import com.intellij.lang.Language
import com.intellij.openapi.actionSystem.DataContext
import com.intellij.openapi.extensions.ExtensionPointName
import com.intellij.openapi.util.TextRange
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiFile
/**
* Provides support for languages that either are Markdown or are compatible with Markdown-like features.
@@ -20,6 +23,14 @@ interface MarkdownCompatibilityChecker {
}
fun isSupportedLanguage(language: Language): Boolean
fun isSupportedElement(element: PsiElement): Boolean {
return isSupportedLanguage(element.containingFile.language)
}
fun isSupportedRange(file: PsiFile, range: TextRange): Boolean {
return isSupportedLanguage(file.language)
}
fun isSupportedContext(language: Language, dataContext: DataContext?): Boolean {
return isSupportedLanguage(language)
@@ -4,7 +4,10 @@ import com.intellij.lang.Language
import com.intellij.openapi.actionSystem.DataContext
import com.intellij.openapi.fileTypes.FileType
import com.intellij.openapi.fileTypes.FileTypeRegistry
import com.intellij.openapi.util.TextRange
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiFile
fun Language.isMarkdownLanguage(): Boolean {
return this == MarkdownLanguage.INSTANCE
@@ -14,6 +17,14 @@ fun Language.supportsMarkdown(dataContext: DataContext? = null): Boolean {
return MarkdownCompatibilityChecker.EP_NAME.extensionList.any { it.isSupportedContext(this, dataContext) }
}
fun PsiElement.supportsMarkdown(): Boolean {
return MarkdownCompatibilityChecker.EP_NAME.extensionList.any { it.isSupportedElement(this) }
}
fun PsiFile.supportsMarkdown(range: TextRange): Boolean {
return MarkdownCompatibilityChecker.EP_NAME.extensionList.any { it.isSupportedRange(this, range) }
}
fun FileType.isMarkdownType(): Boolean {
return this == MarkdownFileType.INSTANCE
}
@@ -0,0 +1,87 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package org.intellij.plugins.markdown.editor
import com.intellij.codeInsight.editorActions.CopyPastePreProcessor
import com.intellij.testFramework.fixtures.LightPlatformCodeInsightFixture4TestCase
import org.junit.Test
class MarkdownLinkPastePreProcessorTest : LightPlatformCodeInsightFixture4TestCase() {
@Test
fun `creates link in Markdown text`() {
assertPreprocessedText("Some <selection>text</selection>", "[text](https://example.com)")
}
@Test
fun `creates link in Markdown formatting contexts`() {
assertPreprocessedText("- *<selection>list item</selection>*", "[list item](https://example.com)")
assertPreprocessedText("| Column |\n| --- |\n| <selection>table cell</selection> |", "[table cell](https://example.com)")
}
@Test
fun `does not create link in fenced code block`() {
assertPreprocessedText("""
```
<selection>some code</selection>
```
""".trimIndent(), "https://example.com")
}
@Test
fun `does not create link in indented code block`() {
assertPreprocessedText(" <selection>some code</selection>", "https://example.com")
}
@Test
fun `does not create link in inline code`() {
assertPreprocessedText("`<selection>some code</selection>`", "https://example.com")
}
@Test
fun `does not create nested link`() {
assertPreprocessedText("[<selection>link text</selection>](https://old.example.com)", "https://example.com")
}
@Test
fun `does not create link from an existing link`() {
assertPreprocessedText("<selection>[link](https://old.example.com)</selection>", "https://example.com")
assertPreprocessedText("<selection>[link][label]</selection>\n\n[label]: https://old.example.com", "https://example.com")
assertPreprocessedText("<selection>[label]</selection>\n\n[label]: https://old.example.com", "https://example.com")
assertPreprocessedText("[link](<selection>https://old.example.com</selection>)", "https://example.com")
}
@Test
fun `does not create link in autolink`() {
assertPreprocessedText("<selection>https://old.example.com</selection>", "https://example.com")
assertPreprocessedText("<<selection>https://old.example.com</selection>>", "https://example.com")
}
@Test
fun `does not create link in HTML block`() {
assertPreprocessedText("<div>\n<selection>some text</selection>\n</div>", "https://example.com")
}
@Test
fun `does not create link in front matter`() {
assertPreprocessedText("---\ntitle: <selection>some text</selection>\n---", "https://example.com")
}
@Test
fun `does not create link from blank selection`() {
assertPreprocessedText("Some <selection> </selection> text", "https://example.com")
}
private fun assertPreprocessedText(fileText: String, expected: String) {
myFixture.configureByText("test.md", fileText)
val processor = CopyPastePreProcessor.EP_NAME.extensionList.single {
it.javaClass.name == "org.intellij.plugins.markdown.editor.MarkdownLinkPastePreProcessor"
}
val actual = processor.preprocessOnPaste(
project,
myFixture.file,
myFixture.editor,
"https://example.com",
null,
)
assertEquals(expected, actual)
}
}