From 1b8403e5b98f6d06d68e9ca29320e10cbcddea76 Mon Sep 17 00:00:00 2001 From: Anna Solomennik Date: Thu, 13 Aug 2026 07:10:31 +0000 Subject: [PATCH] IJPL-93440 Markdown: Create link from selection: do not create link in code snippets etc GitOrigin-RevId: 4cf115e6079fdd241167ebbd1677cd8c56d5bdc9 --- .../JupyterMarkdownCompatibilityChecker.kt | 14 +++ .../editor/MarkdownLinkPastePreProcessor.kt | 37 +++++++- .../lang/MarkdownCompatibilityChecker.kt | 11 +++ .../markdown/lang/MarkdownLanguageUtils.kt | 11 +++ .../MarkdownLinkPastePreProcessorTest.kt | 87 +++++++++++++++++++ 5 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 plugins/markdown/test/src/org/intellij/plugins/markdown/editor/MarkdownLinkPastePreProcessorTest.kt diff --git a/notebooks/visualization/src/com/intellij/notebooks/visualization/JupyterMarkdownCompatibilityChecker.kt b/notebooks/visualization/src/com/intellij/notebooks/visualization/JupyterMarkdownCompatibilityChecker.kt index 4a6db506f40a..e5e215085439 100644 --- a/notebooks/visualization/src/com/intellij/notebooks/visualization/JupyterMarkdownCompatibilityChecker.kt +++ b/notebooks/visualization/src/com/intellij/notebooks/visualization/JupyterMarkdownCompatibilityChecker.kt @@ -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 + } } \ No newline at end of file diff --git a/plugins/markdown/core/src/org/intellij/plugins/markdown/editor/MarkdownLinkPastePreProcessor.kt b/plugins/markdown/core/src/org/intellij/plugins/markdown/editor/MarkdownLinkPastePreProcessor.kt index 8594395c63af..e1c2b4b3822f 100644 --- a/plugins/markdown/core/src/org/intellij/plugins/markdown/editor/MarkdownLinkPastePreProcessor.kt +++ b/plugins/markdown/core/src/org/intellij/plugins/markdown/editor/MarkdownLinkPastePreProcessor.kt @@ -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, +) diff --git a/plugins/markdown/core/src/org/intellij/plugins/markdown/lang/MarkdownCompatibilityChecker.kt b/plugins/markdown/core/src/org/intellij/plugins/markdown/lang/MarkdownCompatibilityChecker.kt index fcd19a1be95a..791e52c73240 100644 --- a/plugins/markdown/core/src/org/intellij/plugins/markdown/lang/MarkdownCompatibilityChecker.kt +++ b/plugins/markdown/core/src/org/intellij/plugins/markdown/lang/MarkdownCompatibilityChecker.kt @@ -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) diff --git a/plugins/markdown/core/src/org/intellij/plugins/markdown/lang/MarkdownLanguageUtils.kt b/plugins/markdown/core/src/org/intellij/plugins/markdown/lang/MarkdownLanguageUtils.kt index 4f2ad5f0dd62..6d24000b9b2d 100644 --- a/plugins/markdown/core/src/org/intellij/plugins/markdown/lang/MarkdownLanguageUtils.kt +++ b/plugins/markdown/core/src/org/intellij/plugins/markdown/lang/MarkdownLanguageUtils.kt @@ -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 } diff --git a/plugins/markdown/test/src/org/intellij/plugins/markdown/editor/MarkdownLinkPastePreProcessorTest.kt b/plugins/markdown/test/src/org/intellij/plugins/markdown/editor/MarkdownLinkPastePreProcessorTest.kt new file mode 100644 index 000000000000..700153394b4f --- /dev/null +++ b/plugins/markdown/test/src/org/intellij/plugins/markdown/editor/MarkdownLinkPastePreProcessorTest.kt @@ -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 text", "[text](https://example.com)") + } + + @Test + fun `creates link in Markdown formatting contexts`() { + assertPreprocessedText("- *list item*", "[list item](https://example.com)") + assertPreprocessedText("| Column |\n| --- |\n| table cell |", "[table cell](https://example.com)") + } + + @Test + fun `does not create link in fenced code block`() { + assertPreprocessedText(""" + ``` + some code + ``` + """.trimIndent(), "https://example.com") + } + + @Test + fun `does not create link in indented code block`() { + assertPreprocessedText(" some code", "https://example.com") + } + + @Test + fun `does not create link in inline code`() { + assertPreprocessedText("`some code`", "https://example.com") + } + + @Test + fun `does not create nested link`() { + assertPreprocessedText("[link text](https://old.example.com)", "https://example.com") + } + + @Test + fun `does not create link from an existing link`() { + assertPreprocessedText("[link](https://old.example.com)", "https://example.com") + assertPreprocessedText("[link][label]\n\n[label]: https://old.example.com", "https://example.com") + assertPreprocessedText("[label]\n\n[label]: https://old.example.com", "https://example.com") + assertPreprocessedText("[link](https://old.example.com)", "https://example.com") + } + + @Test + fun `does not create link in autolink`() { + assertPreprocessedText("https://old.example.com", "https://example.com") + assertPreprocessedText("<https://old.example.com>", "https://example.com") + } + + @Test + fun `does not create link in HTML block`() { + assertPreprocessedText("
\nsome text\n
", "https://example.com") + } + + @Test + fun `does not create link in front matter`() { + assertPreprocessedText("---\ntitle: some text\n---", "https://example.com") + } + + @Test + fun `does not create link from blank selection`() { + assertPreprocessedText("Some 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) + } +}