diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index 8d81b3fcc03..a80b0294786 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -466,10 +466,20 @@ "description": "%markdown.server.log.desc%" }, "markdown.editor.drop.enabled": { - "type": "boolean", - "default": true, + "type": "string", + "scope": "resource", "markdownDescription": "%configuration.markdown.editor.drop.enabled%", - "scope": "resource" + "default": "smart", + "enum": [ + "always", + "smart", + "never" + ], + "markdownEnumDescriptions": [ + "%configuration.markdown.editor.drop.enabled.always%", + "%configuration.markdown.editor.drop.enabled.smart%", + "%configuration.markdown.editor.drop.enabled.never%" + ] }, "markdown.editor.drop.copyIntoWorkspace": { "type": "string", @@ -488,7 +498,17 @@ "type": "boolean", "scope": "resource", "markdownDescription": "%configuration.markdown.editor.filePaste.enabled%", - "default": true + "default": "smart", + "enum": [ + "always", + "smart", + "never" + ], + "markdownEnumDescriptions": [ + "%configuration.markdown.editor.filePaste.enabled.always%", + "%configuration.markdown.editor.filePaste.enabled.smart%", + "%configuration.markdown.editor.filePaste.enabled.never%" + ] }, "markdown.editor.filePaste.copyIntoWorkspace": { "type": "string", diff --git a/extensions/markdown-language-features/package.nls.json b/extensions/markdown-language-features/package.nls.json index af77144f15c..61ccbf534eb 100644 --- a/extensions/markdown-language-features/package.nls.json +++ b/extensions/markdown-language-features/package.nls.json @@ -38,8 +38,14 @@ "configuration.markdown.suggest.paths.includeWorkspaceHeaderCompletions.onDoubleHash": "Enable workspace header suggestions after typing `##` in a path, for example: `[link text](##`.", "configuration.markdown.suggest.paths.includeWorkspaceHeaderCompletions.onSingleOrDoubleHash": "Enable workspace header suggestions after typing either `##` or `#` in a path, for example: `[link text](#` or `[link text](##`.", "configuration.markdown.editor.drop.enabled": "Enable dropping files into a Markdown editor while holding Shift. Requires enabling `#editor.dropIntoEditor.enabled#`.", + "configuration.markdown.editor.drop.always": "Always insert Markdown links.", + "configuration.markdown.editor.drop.smart": "Smartly create Markdown links by default when not dropping into a code block or other special element. Use the drop widget to switch between pasting as plain text or as Markdown links.", + "configuration.markdown.editor.drop.never": "Never create Markdown links.", "configuration.markdown.editor.drop.copyIntoWorkspace": "Controls if files outside of the workspace that are dropped into a Markdown editor should be copied into the workspace.\n\nUse `#markdown.copyFiles.destination#` to configure where copied dropped files should be created", "configuration.markdown.editor.filePaste.enabled": "Enable pasting files into a Markdown editor to create Markdown links. Requires enabling `#editor.pasteAs.enabled#`.", + "configuration.markdown.editor.filePaste.always": "Always insert Markdown links.", + "configuration.markdown.editor.filePaste.smart": "Smartly create Markdown links by default when not pasting into a code block or other special element. Use the paste widget to switch between pasting as plain text or as Markdown links.", + "configuration.markdown.editor.filePaste.never": "Never create Markdown links.", "configuration.markdown.editor.filePaste.copyIntoWorkspace": "Controls if files outside of the workspace that are pasted into a Markdown editor should be copied into the workspace.\n\nUse `#markdown.copyFiles.destination#` to configure where copied files should be created.", "configuration.copyIntoWorkspace.mediaFiles": "Try to copy external image and video files into the workspace.", "configuration.copyIntoWorkspace.never": "Do not copy external files into the workspace.", diff --git a/extensions/markdown-language-features/src/extension.shared.ts b/extensions/markdown-language-features/src/extension.shared.ts index d6499591039..e8758bad832 100644 --- a/extensions/markdown-language-features/src/extension.shared.ts +++ b/extensions/markdown-language-features/src/extension.shared.ts @@ -58,7 +58,7 @@ function registerMarkdownLanguageFeatures( // Language features registerDiagnosticSupport(selector, commandManager), registerFindFileReferenceSupport(commandManager, client), - registerResourceDropOrPasteSupport(selector), + registerResourceDropOrPasteSupport(selector, parser), registerPasteUrlSupport(selector, parser), registerUpdateLinksOnRename(client), ); diff --git a/extensions/markdown-language-features/src/languageFeatures/copyFiles/dropOrPasteResource.ts b/extensions/markdown-language-features/src/languageFeatures/copyFiles/dropOrPasteResource.ts index 821164c6ed2..73a012dacec 100644 --- a/extensions/markdown-language-features/src/languageFeatures/copyFiles/dropOrPasteResource.ts +++ b/extensions/markdown-language-features/src/languageFeatures/copyFiles/dropOrPasteResource.ts @@ -4,12 +4,20 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { IMdParser } from '../../markdownEngine'; import { coalesce } from '../../util/arrays'; import { getParentDocumentUri } from '../../util/document'; import { Mime, mediaMimes } from '../../util/mimes'; import { Schemes } from '../../util/schemes'; import { NewFilePathGenerator } from './newFilePathGenerator'; -import { createInsertUriListEdit, createUriListSnippet, getSnippetLabel } from './shared'; +import { DropOrPasteEdit, createInsertUriListEdit, createUriListSnippet, getSnippetLabel } from './shared'; +import { InsertMarkdownLink, shouldInsertMarkdownLinkByDefault } from './smartDropOrPaste'; +import { UriList } from '../../util/uriList'; + +enum CopyFilesSettings { + Never = 'never', + MediaFiles = 'mediaFiles', +} /** * Provides support for pasting or dropping resources into markdown documents. @@ -35,127 +43,146 @@ class ResourcePasteOrDropProvider implements vscode.DocumentPasteEditProvider, v vscode.DocumentPasteEditKind.Empty.append('markdown', 'image', 'attachment'), ]; + constructor( + private readonly _parser: IMdParser, + ) { } + public async provideDocumentDropEdits( document: vscode.TextDocument, position: vscode.Position, dataTransfer: vscode.DataTransfer, token: vscode.CancellationToken, ): Promise { - const enabled = vscode.workspace.getConfiguration('markdown', document).get('editor.drop.enabled', true); - if (!enabled) { + const edit = await this._createEdit(document, [new vscode.Range(position, position)], dataTransfer, { + insert: this._getEnabled(document, 'editor.drop.enabled'), + copyIntoWorkspace: vscode.workspace.getConfiguration('markdown', document).get('editor.drop.copyIntoWorkspace', CopyFilesSettings.MediaFiles) + }, undefined, token); + + if (!edit || token.isCancellationRequested) { return; } - const filesEdit = await this._getMediaFilesDropEdit(document, dataTransfer, token); - if (filesEdit) { - return filesEdit; - } - - if (token.isCancellationRequested) { - return; - } - - return this._createEditFromUriListData(document, [new vscode.Range(position, position)], dataTransfer, token); + const dropEdit = new vscode.DocumentDropEdit(edit.snippet); + dropEdit.title = edit.label; + dropEdit.kind = ResourcePasteOrDropProvider.kind; + dropEdit.additionalEdit = edit.additionalEdits; + dropEdit.yieldTo = [...this._yieldTo, ...edit.yieldTo]; + return dropEdit; } public async provideDocumentPasteEdits( document: vscode.TextDocument, ranges: readonly vscode.Range[], dataTransfer: vscode.DataTransfer, - _context: vscode.DocumentPasteEditContext, + context: vscode.DocumentPasteEditContext, token: vscode.CancellationToken, ): Promise { - const enabled = vscode.workspace.getConfiguration('markdown', document).get('editor.filePaste.enabled', true); - if (!enabled) { + const edit = await this._createEdit(document, ranges, dataTransfer, { + insert: this._getEnabled(document, 'editor.paste.enabled'), + copyIntoWorkspace: vscode.workspace.getConfiguration('markdown', document).get('editor.paste.copyIntoWorkspace', CopyFilesSettings.MediaFiles) + }, context, token); + + if (!edit || token.isCancellationRequested) { return; } - const createEdit = await this._getMediaFilesPasteEdit(document, dataTransfer, token); - if (createEdit) { - return [createEdit]; + const pasteEdit = new vscode.DocumentPasteEdit(edit.snippet, edit.label, ResourcePasteOrDropProvider.kind); + pasteEdit.additionalEdit = edit.additionalEdits; + pasteEdit.yieldTo = [...this._yieldTo, ...edit.yieldTo]; + return [pasteEdit]; + } + + private _getEnabled(document: vscode.TextDocument, settingName: string): InsertMarkdownLink { + const setting = vscode.workspace.getConfiguration('markdown', document).get(settingName, true); + // Convert old boolean values to new enum setting + if (setting === false) { + return InsertMarkdownLink.Never; + } else if (setting === true) { + return InsertMarkdownLink.Smart; + } else { + return setting; + } + } + + private async _createEdit( + document: vscode.TextDocument, + ranges: readonly vscode.Range[], + dataTransfer: vscode.DataTransfer, + settings: { + insert: InsertMarkdownLink; + copyIntoWorkspace: CopyFilesSettings; + }, + context: vscode.DocumentPasteEditContext | undefined, + token: vscode.CancellationToken, + ): Promise { + if (settings.insert === InsertMarkdownLink.Never) { + return; } + let edit = await this._createEditForMediaFiles(document, dataTransfer, settings.copyIntoWorkspace, token); if (token.isCancellationRequested) { return; } - const edit = await this._createEditFromUriListData(document, ranges, dataTransfer, token); - return edit ? [edit] : undefined; + if (!edit) { + edit = await this._createEditFromUriListData(document, ranges, dataTransfer, context, token); + } + + if (!edit || token.isCancellationRequested) { + return; + } + + if (!(await shouldInsertMarkdownLinkByDefault(this._parser, document, settings.insert, ranges, token))) { + edit.yieldTo.push(vscode.DocumentPasteEditKind.Empty.append('uri')); + } + + return edit; } private async _createEditFromUriListData( document: vscode.TextDocument, ranges: readonly vscode.Range[], dataTransfer: vscode.DataTransfer, + context: vscode.DocumentPasteEditContext | undefined, token: vscode.CancellationToken, - ): Promise { - const uriList = await dataTransfer.get(Mime.textUriList)?.asString(); - if (!uriList || token.isCancellationRequested) { + ): Promise { + const uriListData = await dataTransfer.get(Mime.textUriList)?.asString(); + if (!uriListData || token.isCancellationRequested) { return; } - const pasteEdit = createInsertUriListEdit(document, ranges, uriList); - if (!pasteEdit) { + const uriList = UriList.from(uriListData); + if (!uriList.entries.length) { return; } - const uriEdit = new vscode.DocumentPasteEdit('', pasteEdit.label, ResourcePasteOrDropProvider.kind); - const edit = new vscode.WorkspaceEdit(); - edit.set(document.uri, pasteEdit.edits); - uriEdit.additionalEdit = edit; - uriEdit.yieldTo = this._yieldTo; - return uriEdit; - } + // Disable ourselves if there's also a text entry with the same content as our list, + // unless we are explicitly requested. + if (uriList.entries.length === 1 && !context?.only?.contains(ResourcePasteOrDropProvider.kind)) { + const text = await dataTransfer.get(Mime.textPlain)?.asString(); + if (token.isCancellationRequested) { + return; + } - private async _getMediaFilesPasteEdit( - document: vscode.TextDocument, - dataTransfer: vscode.DataTransfer, - token: vscode.CancellationToken, - ): Promise { - if (getParentDocumentUri(document.uri).scheme === Schemes.untitled) { - return; + if (text && textMatchesUriList(text, uriList)) { + return; + } } - const copyFilesIntoWorkspace = vscode.workspace.getConfiguration('markdown', document).get<'mediaFiles' | 'never'>('editor.filePaste.copyIntoWorkspace', 'mediaFiles'); - if (copyFilesIntoWorkspace !== 'mediaFiles') { - return; - } - - const edit = await this._createEditForMediaFiles(document, dataTransfer, token); + const edit = createInsertUriListEdit(document, ranges, uriList); if (!edit) { return; } - const pasteEdit = new vscode.DocumentPasteEdit(edit.snippet, edit.label, ResourcePasteOrDropProvider.kind); - pasteEdit.additionalEdit = edit.additionalEdits; - pasteEdit.yieldTo = this._yieldTo; - return pasteEdit; - } + const additionalEdits = new vscode.WorkspaceEdit(); + additionalEdits.set(document.uri, edit.edits); - private async _getMediaFilesDropEdit( - document: vscode.TextDocument, - dataTransfer: vscode.DataTransfer, - token: vscode.CancellationToken, - ): Promise { - if (getParentDocumentUri(document.uri).scheme === Schemes.untitled) { - return; - } - - const copyIntoWorkspace = vscode.workspace.getConfiguration('markdown', document).get<'mediaFiles' | 'never'>('editor.drop.copyIntoWorkspace', 'mediaFiles'); - if (copyIntoWorkspace !== 'mediaFiles') { - return; - } - - const edit = await this._createEditForMediaFiles(document, dataTransfer, token); - if (!edit) { - return; - } - - const dropEdit = new vscode.DocumentDropEdit(edit.snippet); - dropEdit.title = edit.label; - dropEdit.additionalEdit = edit.additionalEdits; - dropEdit.yieldTo = this._yieldTo; - return dropEdit; + return { + label: edit.label, + snippet: new vscode.SnippetString(''), + additionalEdits, + yieldTo: [] + }; } /** @@ -166,8 +193,13 @@ class ResourcePasteOrDropProvider implements vscode.DocumentPasteEditProvider, v private async _createEditForMediaFiles( document: vscode.TextDocument, dataTransfer: vscode.DataTransfer, + copyIntoWorkspace: CopyFilesSettings, token: vscode.CancellationToken, - ): Promise<{ snippet: vscode.SnippetString; label: string; additionalEdits: vscode.WorkspaceEdit } | undefined> { + ): Promise { + if (copyIntoWorkspace !== CopyFilesSettings.MediaFiles || getParentDocumentUri(document.uri).scheme === Schemes.untitled) { + return; + } + interface FileEntry { readonly uri: vscode.Uri; readonly newFile?: { readonly contents: vscode.DataTransferFile; readonly overwrite: boolean }; @@ -202,36 +234,50 @@ class ResourcePasteOrDropProvider implements vscode.DocumentPasteEditProvider, v return; } - const workspaceEdit = new vscode.WorkspaceEdit(); + const snippet = createUriListSnippet(document.uri, fileEntries); + if (!snippet) { + return; + } + + const additionalEdits = new vscode.WorkspaceEdit(); for (const entry of fileEntries) { if (entry.newFile) { - workspaceEdit.createFile(entry.uri, { + additionalEdits.createFile(entry.uri, { contents: entry.newFile.contents, overwrite: entry.newFile.overwrite, }); } } - const snippet = createUriListSnippet(document.uri, fileEntries); - if (!snippet) { - return; - } - return { snippet: snippet.snippet, label: getSnippetLabel(snippet), - additionalEdits: workspaceEdit, + additionalEdits, + yieldTo: [], }; } } -export function registerResourceDropOrPasteSupport(selector: vscode.DocumentSelector): vscode.Disposable { +function textMatchesUriList(text: string, uriList: UriList): boolean { + if (text === uriList.entries[0].str) { + return true; + } + + try { + const uri = vscode.Uri.parse(text); + return uriList.entries.some(entry => entry.uri.toString() === uri.toString()); + } catch { + return false; + } +} + +export function registerResourceDropOrPasteSupport(selector: vscode.DocumentSelector, parser: IMdParser): vscode.Disposable { return vscode.Disposable.from( - vscode.languages.registerDocumentPasteEditProvider(selector, new ResourcePasteOrDropProvider(), { + vscode.languages.registerDocumentPasteEditProvider(selector, new ResourcePasteOrDropProvider(parser), { providedPasteEditKinds: [ResourcePasteOrDropProvider.kind], pasteMimeTypes: ResourcePasteOrDropProvider.mimeTypes, }), - vscode.languages.registerDocumentDropEditProvider(selector, new ResourcePasteOrDropProvider(), { + vscode.languages.registerDocumentDropEditProvider(selector, new ResourcePasteOrDropProvider(parser), { providedDropEditKinds: [ResourcePasteOrDropProvider.kind], dropMimeTypes: ResourcePasteOrDropProvider.mimeTypes, }), diff --git a/extensions/markdown-language-features/src/languageFeatures/copyFiles/pasteUrlProvider.ts b/extensions/markdown-language-features/src/languageFeatures/copyFiles/pasteUrlProvider.ts index dbbc2a1a231..7fcff576a3d 100644 --- a/extensions/markdown-language-features/src/languageFeatures/copyFiles/pasteUrlProvider.ts +++ b/extensions/markdown-language-features/src/languageFeatures/copyFiles/pasteUrlProvider.ts @@ -5,22 +5,10 @@ import * as vscode from 'vscode'; import { IMdParser } from '../../markdownEngine'; -import { ITextDocument } from '../../types/textDocument'; import { Mime } from '../../util/mimes'; -import { Schemes } from '../../util/schemes'; import { createInsertUriListEdit } from './shared'; - -export enum PasteUrlAsMarkdownLink { - Always = 'always', - SmartWithSelection = 'smartWithSelection', - Smart = 'smart', - Never = 'never' -} - -function getPasteUrlAsFormattedLinkSetting(document: vscode.TextDocument): PasteUrlAsMarkdownLink { - return vscode.workspace.getConfiguration('markdown', document) - .get('editor.pasteUrlAsFormattedLink.enabled', PasteUrlAsMarkdownLink.SmartWithSelection); -} +import { InsertMarkdownLink, findValidUriInText, shouldInsertMarkdownLinkByDefault } from './smartDropOrPaste'; +import { UriList } from '../../util/uriList'; /** * Adds support for pasting text uris to create markdown links. @@ -44,8 +32,9 @@ class PasteUrlEditProvider implements vscode.DocumentPasteEditProvider { _context: vscode.DocumentPasteEditContext, token: vscode.CancellationToken, ): Promise { - const pasteUrlSetting = getPasteUrlAsFormattedLinkSetting(document); - if (pasteUrlSetting === PasteUrlAsMarkdownLink.Never) { + const pasteUrlSetting = vscode.workspace.getConfiguration('markdown', document) + .get('editor.pasteUrlAsFormattedLink.enabled', InsertMarkdownLink.SmartWithSelection); + if (pasteUrlSetting === InsertMarkdownLink.Never) { return; } @@ -60,7 +49,7 @@ class PasteUrlEditProvider implements vscode.DocumentPasteEditProvider { return; } - const edit = createInsertUriListEdit(document, ranges, uriText, { preserveAbsoluteUris: true }); + const edit = createInsertUriListEdit(document, ranges, UriList.from(uriText), { preserveAbsoluteUris: true }); if (!edit) { return; } @@ -71,7 +60,10 @@ class PasteUrlEditProvider implements vscode.DocumentPasteEditProvider { pasteEdit.additionalEdit = workspaceEdit; if (!(await shouldInsertMarkdownLinkByDefault(this._parser, document, pasteUrlSetting, ranges, token))) { - pasteEdit.yieldTo = [vscode.DocumentPasteEditKind.Empty.append('text')]; + pasteEdit.yieldTo = [ + vscode.DocumentPasteEditKind.Empty.append('text'), + vscode.DocumentPasteEditKind.Empty.append('uri') + ]; } return [pasteEdit]; @@ -84,168 +76,3 @@ export function registerPasteUrlSupport(selector: vscode.DocumentSelector, parse pasteMimeTypes: PasteUrlEditProvider.pasteMimeTypes, }); } - -const smartPasteLineRegexes = [ - { regex: /(\[[^\[\]]*](?:\([^\(\)]*\)|\[[^\[\]]*]))/g }, // In a Markdown link - { regex: /\$\$[\s\S]*?\$\$/gm }, // In a fenced math block - { regex: /`[^`]*`/g }, // In inline code - { regex: /\$[^$]*\$/g }, // In inline math - { regex: /<[^<>\s]*>/g }, // Autolink - { regex: /^[ ]{0,3}\[\w+\]:\s.*$/g, isWholeLine: true }, // Block link definition (needed as tokens are not generated for these) -]; - -export async function shouldInsertMarkdownLinkByDefault( - parser: IMdParser, - document: ITextDocument, - pasteUrlSetting: PasteUrlAsMarkdownLink, - ranges: readonly vscode.Range[], - token: vscode.CancellationToken, -): Promise { - switch (pasteUrlSetting) { - case PasteUrlAsMarkdownLink.Always: { - return true; - } - case PasteUrlAsMarkdownLink.Smart: { - return checkSmart(); - } - case PasteUrlAsMarkdownLink.SmartWithSelection: { - // At least one range must not be empty - if (!ranges.some(range => document.getText(range).trim().length > 0)) { - return false; - } - // And all ranges must be smart - return checkSmart(); - } - default: { - return false; - } - } - - async function checkSmart(): Promise { - return (await Promise.all(ranges.map(range => shouldSmartPasteForSelection(parser, document, range, token)))).every(x => x); - } -} - -const textTokenTypes = new Set(['paragraph_open', 'inline', 'heading_open', 'ordered_list_open', 'bullet_list_open', 'list_item_open', 'blockquote_open']); - -async function shouldSmartPasteForSelection( - parser: IMdParser, - document: ITextDocument, - selectedRange: vscode.Range, - token: vscode.CancellationToken, -): Promise { - // Disable for multi-line selections - if (selectedRange.start.line !== selectedRange.end.line) { - return false; - } - - const rangeText = document.getText(selectedRange); - // Disable when the selection is already a link - if (findValidUriInText(rangeText)) { - return false; - } - - if (/\[.*\]\(.*\)/.test(rangeText) || /!\[.*\]\(.*\)/.test(rangeText)) { - return false; - } - - // Check if selection is inside a special block level element using markdown engine - const tokens = await parser.tokenize(document); - if (token.isCancellationRequested) { - return false; - } - - for (let i = 0; i < tokens.length; i++) { - const token = tokens[i]; - if (!token.map) { - continue; - } - if (token.map[0] <= selectedRange.start.line && token.map[1] > selectedRange.start.line) { - if (!textTokenTypes.has(token.type)) { - return false; - } - } - - // Special case for html such as: - // - // - // | - // - // - // In this case pasting will cause the html block to be created even though the cursor is not currently inside a block - if (token.type === 'html_block' && token.map[1] === selectedRange.start.line) { - const nextToken = tokens.at(i + 1); - // The next token does not need to be a html_block, but it must be on the next line - if (nextToken?.map?.[0] === selectedRange.end.line + 1) { - return false; - } - } - } - - // Run additional regex checks on the current line to check if we are inside an inline element - const line = document.getText(new vscode.Range(selectedRange.start.line, 0, selectedRange.start.line, Number.MAX_SAFE_INTEGER)); - for (const regex of smartPasteLineRegexes) { - for (const match of line.matchAll(regex.regex)) { - if (match.index === undefined) { - continue; - } - - if (regex.isWholeLine) { - return false; - } - - if (selectedRange.start.character > match.index && selectedRange.start.character < match.index + match[0].length) { - return false; - } - } - } - - return true; -} - - -const externalUriSchemes: ReadonlySet = new Set([ - Schemes.http, - Schemes.https, - Schemes.mailto, - Schemes.file, -]); - -export function findValidUriInText(text: string): string | undefined { - const trimmedUrlList = text.trim(); - - if ( - !/^\S+$/.test(trimmedUrlList) // Uri must consist of a single sequence of characters without spaces - || !trimmedUrlList.includes(':') // And it must have colon somewhere for the scheme. We will verify the schema again later - ) { - return; - } - - let uri: vscode.Uri; - try { - uri = vscode.Uri.parse(trimmedUrlList); - } catch { - // Could not parse - return; - } - - // `Uri.parse` is lenient and will return a `file:` uri even for non-uri text such as `abc` - // Make sure that the resolved scheme starts the original text - if (!trimmedUrlList.toLowerCase().startsWith(uri.scheme.toLowerCase() + ':')) { - return; - } - - // Only enable for an allow list of schemes. Otherwise this can be accidentally activated for non-uri text - // such as `c:\abc` or `value:foo` - if (!externalUriSchemes.has(uri.scheme.toLowerCase())) { - return; - } - - // Some part of the uri must not be empty - // This disables the feature for text such as `http:` - if (!uri.authority && uri.path.length < 2 && !uri.query && !uri.fragment) { - return; - } - - return trimmedUrlList; -} diff --git a/extensions/markdown-language-features/src/languageFeatures/copyFiles/shared.ts b/extensions/markdown-language-features/src/languageFeatures/copyFiles/shared.ts index 8bfc9ae2ff5..563c125cfc6 100644 --- a/extensions/markdown-language-features/src/languageFeatures/copyFiles/shared.ts +++ b/extensions/markdown-language-features/src/languageFeatures/copyFiles/shared.ts @@ -7,11 +7,10 @@ import * as path from 'path'; import * as vscode from 'vscode'; import * as URI from 'vscode-uri'; import { ITextDocument } from '../../types/textDocument'; -import { coalesce } from '../../util/arrays'; import { getDocumentDir } from '../../util/document'; import { Schemes } from '../../util/schemes'; +import { UriList } from '../../util/uriList'; import { resolveSnippet } from './snippets'; -import { parseUriList } from '../../util/uriList'; enum MediaKind { Image, @@ -68,24 +67,13 @@ export function getSnippetLabel(counter: { insertedAudioVideoCount: number; inse export function createInsertUriListEdit( document: ITextDocument, ranges: readonly vscode.Range[], - urlList: string, + urlList: UriList, options?: UriListSnippetOptions, ): { edits: vscode.SnippetTextEdit[]; label: string } | undefined { - if (!ranges.length) { + if (!ranges.length || !urlList.entries.length) { return; } - const entries = coalesce(parseUriList(urlList).map(line => { - try { - return { uri: vscode.Uri.parse(line), str: line }; - } catch { - // Uri parse failure - return undefined; - } - })); - if (!entries.length) { - return; - } const edits: vscode.SnippetTextEdit[] = []; @@ -94,14 +82,14 @@ export function createInsertUriListEdit( let insertedAudioVideoCount = 0; // Use 1 for all empty ranges but give non-empty range unique indices starting after 1 - let placeHolderStartIndex = 1 + entries.length; + let placeHolderStartIndex = 1 + urlList.entries.length; // Sort ranges by start position const orderedRanges = [...ranges].sort((a, b) => a.start.compareTo(b.start)); const allRangesAreEmpty = orderedRanges.every(range => range.isEmpty); for (const range of orderedRanges) { - const snippet = createUriListSnippet(document.uri, entries, { + const snippet = createUriListSnippet(document.uri, urlList.entries, { placeholderText: range.isEmpty ? undefined : document.getText(range), placeholderStartIndex: allRangesAreEmpty ? 1 : placeHolderStartIndex, ...options, @@ -114,7 +102,7 @@ export function createInsertUriListEdit( insertedImageCount += snippet.insertedImageCount; insertedAudioVideoCount += snippet.insertedAudioVideoCount; - placeHolderStartIndex += entries.length; + placeHolderStartIndex += urlList.entries.length; edits.push(new vscode.SnippetTextEdit(range, snippet.snippet)); } @@ -273,3 +261,10 @@ function needsBracketLink(mdPath: string): boolean { return nestingCount > 0; } + +export interface DropOrPasteEdit { + readonly snippet: vscode.SnippetString; + readonly label: string; + readonly additionalEdits: vscode.WorkspaceEdit; + readonly yieldTo: vscode.DocumentPasteEditKind[]; +} diff --git a/extensions/markdown-language-features/src/languageFeatures/copyFiles/smartDropOrPaste.ts b/extensions/markdown-language-features/src/languageFeatures/copyFiles/smartDropOrPaste.ts new file mode 100644 index 00000000000..deaa4b58212 --- /dev/null +++ b/extensions/markdown-language-features/src/languageFeatures/copyFiles/smartDropOrPaste.ts @@ -0,0 +1,188 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { IMdParser } from '../../markdownEngine'; +import { ITextDocument } from '../../types/textDocument'; +import { Schemes } from '../../util/schemes'; + +const smartPasteLineRegexes = [ + { regex: /(\[[^\[\]]*](?:\([^\(\)]*\)|\[[^\[\]]*]))/g }, // In a Markdown link + { regex: /\$\$[\s\S]*?\$\$/gm }, // In a fenced math block + { regex: /`[^`]*`/g }, // In inline code + { regex: /\$[^$]*\$/g }, // In inline math + { regex: /<[^<>\s]*>/g }, // Autolink + { regex: /^[ ]{0,3}\[\w+\]:\s.*$/g, isWholeLine: true }, // Block link definition (needed as tokens are not generated for these) +]; + +export async function shouldInsertMarkdownLinkByDefault( + parser: IMdParser, + document: ITextDocument, + pasteUrlSetting: InsertMarkdownLink, + ranges: readonly vscode.Range[], + token: vscode.CancellationToken +): Promise { + switch (pasteUrlSetting) { + case InsertMarkdownLink.Always: { + return true; + } + case InsertMarkdownLink.Smart: { + return checkSmart(); + } + case InsertMarkdownLink.SmartWithSelection: { + // At least one range must not be empty + if (!ranges.some(range => document.getText(range).trim().length > 0)) { + return false; + } + // And all ranges must be smart + return checkSmart(); + } + default: { + return false; + } + } + + async function checkSmart(): Promise { + return (await Promise.all(ranges.map(range => shouldSmartPasteForSelection(parser, document, range, token)))).every(x => x); + } +} + +const textTokenTypes = new Set([ + 'paragraph_open', + 'inline', + 'heading_open', + 'ordered_list_open', + 'bullet_list_open', + 'list_item_open', + 'blockquote_open', +]); + +async function shouldSmartPasteForSelection( + parser: IMdParser, + document: ITextDocument, + selectedRange: vscode.Range, + token: vscode.CancellationToken +): Promise { + // Disable for multi-line selections + if (selectedRange.start.line !== selectedRange.end.line) { + return false; + } + + const rangeText = document.getText(selectedRange); + // Disable when the selection is already a link + if (findValidUriInText(rangeText)) { + return false; + } + + if (/\[.*\]\(.*\)/.test(rangeText) || /!\[.*\]\(.*\)/.test(rangeText)) { + return false; + } + + // Check if selection is inside a special block level element using markdown engine + const tokens = await parser.tokenize(document); + if (token.isCancellationRequested) { + return false; + } + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + if (!token.map) { + continue; + } + if (token.map[0] <= selectedRange.start.line && token.map[1] > selectedRange.start.line) { + if (!textTokenTypes.has(token.type)) { + return false; + } + } + + // Special case for html such as: + // + // + // | + // + // + // In this case pasting will cause the html block to be created even though the cursor is not currently inside a block + if (token.type === 'html_block' && token.map[1] === selectedRange.start.line) { + const nextToken = tokens.at(i + 1); + // The next token does not need to be a html_block, but it must be on the next line + if (nextToken?.map?.[0] === selectedRange.end.line + 1) { + return false; + } + } + } + + // Run additional regex checks on the current line to check if we are inside an inline element + const line = document.getText(new vscode.Range(selectedRange.start.line, 0, selectedRange.start.line, Number.MAX_SAFE_INTEGER)); + for (const regex of smartPasteLineRegexes) { + for (const match of line.matchAll(regex.regex)) { + if (match.index === undefined) { + continue; + } + + if (regex.isWholeLine) { + return false; + } + + if (selectedRange.start.character > match.index && selectedRange.start.character < match.index + match[0].length) { + return false; + } + } + } + + return true; +} + +const externalUriSchemes: ReadonlySet = new Set([ + Schemes.http, + Schemes.https, + Schemes.mailto, + Schemes.file, +]); + +export function findValidUriInText(text: string): string | undefined { + const trimmedUrlList = text.trim(); + + if (!/^\S+$/.test(trimmedUrlList) // Uri must consist of a single sequence of characters without spaces + || !trimmedUrlList.includes(':') // And it must have colon somewhere for the scheme. We will verify the schema again later + ) { + return; + } + + let uri: vscode.Uri; + try { + uri = vscode.Uri.parse(trimmedUrlList); + } catch { + // Could not parse + return; + } + + // `Uri.parse` is lenient and will return a `file:` uri even for non-uri text such as `abc` + // Make sure that the resolved scheme starts the original text + if (!trimmedUrlList.toLowerCase().startsWith(uri.scheme.toLowerCase() + ':')) { + return; + } + + // Only enable for an allow list of schemes. Otherwise this can be accidentally activated for non-uri text + // such as `c:\abc` or `value:foo` + if (!externalUriSchemes.has(uri.scheme.toLowerCase())) { + return; + } + + // Some part of the uri must not be empty + // This disables the feature for text such as `http:` + if (!uri.authority && uri.path.length < 2 && !uri.query && !uri.fragment) { + return; + } + + return trimmedUrlList; +} + +export enum InsertMarkdownLink { + Always = 'always', + SmartWithSelection = 'smartWithSelection', + Smart = 'smart', + Never = 'never' +} + diff --git a/extensions/markdown-language-features/src/test/pasteUrl.test.ts b/extensions/markdown-language-features/src/test/pasteUrl.test.ts index df863a25652..2afa4465f76 100644 --- a/extensions/markdown-language-features/src/test/pasteUrl.test.ts +++ b/extensions/markdown-language-features/src/test/pasteUrl.test.ts @@ -6,10 +6,11 @@ import * as assert from 'assert'; import 'mocha'; import * as vscode from 'vscode'; import { InMemoryDocument } from '../client/inMemoryDocument'; -import { PasteUrlAsMarkdownLink, findValidUriInText, shouldInsertMarkdownLinkByDefault } from '../languageFeatures/copyFiles/pasteUrlProvider'; import { createInsertUriListEdit } from '../languageFeatures/copyFiles/shared'; -import { createNewMarkdownEngine } from './engine'; +import { InsertMarkdownLink, findValidUriInText, shouldInsertMarkdownLinkByDefault } from '../languageFeatures/copyFiles/smartDropOrPaste'; import { noopToken } from '../util/cancellation'; +import { UriList } from '../util/uriList'; +import { createNewMarkdownEngine } from './engine'; function makeTestDoc(contents: string) { return new InMemoryDocument(vscode.Uri.file('test.md'), contents); @@ -21,7 +22,7 @@ suite('createEditAddingLinksForUriList', () => { // createEditAddingLinksForUriList -> checkSmartPaste -> tryGetUriListSnippet -> createUriListSnippet -> createLinkSnippet const result = createInsertUriListEdit( - new InMemoryDocument(vscode.Uri.file('test.md'), 'hello world!'), [new vscode.Range(0, 0, 0, 12)], 'https://www.microsoft.com/'); + new InMemoryDocument(vscode.Uri.file('test.md'), 'hello world!'), [new vscode.Range(0, 0, 0, 12)], UriList.from('https://www.microsoft.com/')); // need to check the actual result -> snippet value assert.strictEqual(result?.label, 'Insert Markdown Link'); }); @@ -110,27 +111,27 @@ suite('createEditAddingLinksForUriList', () => { suite('createInsertUriListEdit', () => { test('Should create snippet with < > when pasted link has an mismatched parentheses', () => { - const edit = createInsertUriListEdit(makeTestDoc(''), [new vscode.Range(0, 0, 0, 0)], 'https://www.mic(rosoft.com'); + const edit = createInsertUriListEdit(makeTestDoc(''), [new vscode.Range(0, 0, 0, 0)], UriList.from('https://www.mic(rosoft.com')); assert.strictEqual(edit?.edits?.[0].snippet.value, '[${1:text}]()'); }); test('Should create Markdown link snippet when pasteAsMarkdownLink is true', () => { - const edit = createInsertUriListEdit(makeTestDoc(''), [new vscode.Range(0, 0, 0, 0)], 'https://www.microsoft.com'); + const edit = createInsertUriListEdit(makeTestDoc(''), [new vscode.Range(0, 0, 0, 0)], UriList.from('https://www.microsoft.com')); assert.strictEqual(edit?.edits?.[0].snippet.value, '[${1:text}](https://www.microsoft.com)'); }); test('Should use an unencoded URI string in Markdown link when passing in an external browser link', () => { - const edit = createInsertUriListEdit(makeTestDoc(''), [new vscode.Range(0, 0, 0, 0)], 'https://www.microsoft.com'); + const edit = createInsertUriListEdit(makeTestDoc(''), [new vscode.Range(0, 0, 0, 0)], UriList.from('https://www.microsoft.com')); assert.strictEqual(edit?.edits?.[0].snippet.value, '[${1:text}](https://www.microsoft.com)'); }); test('Should not decode an encoded URI string when passing in an external browser link', () => { - const edit = createInsertUriListEdit(makeTestDoc(''), [new vscode.Range(0, 0, 0, 0)], 'https://www.microsoft.com/%20'); + const edit = createInsertUriListEdit(makeTestDoc(''), [new vscode.Range(0, 0, 0, 0)], UriList.from('https://www.microsoft.com/%20')); assert.strictEqual(edit?.edits?.[0].snippet.value, '[${1:text}](https://www.microsoft.com/%20)'); }); test('Should not encode an unencoded URI string when passing in an external browser link', () => { - const edit = createInsertUriListEdit(makeTestDoc(''), [new vscode.Range(0, 0, 0, 0)], 'https://www.example.com/path?query=value&another=value#fragment'); + const edit = createInsertUriListEdit(makeTestDoc(''), [new vscode.Range(0, 0, 0, 0)], UriList.from('https://www.example.com/path?query=value&another=value#fragment')); assert.strictEqual(edit?.edits?.[0].snippet.value, '[${1:text}](https://www.example.com/path?query=value&another=value#fragment)'); }); }); @@ -140,41 +141,41 @@ suite('createEditAddingLinksForUriList', () => { test('Smart should be enabled for selected plain text', async () => { assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('hello world'), PasteUrlAsMarkdownLink.SmartWithSelection, [new vscode.Range(0, 0, 0, 12)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('hello world'), InsertMarkdownLink.SmartWithSelection, [new vscode.Range(0, 0, 0, 12)], noopToken), true); }); test('Smart should be enabled in headers', async () => { assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('# title'), PasteUrlAsMarkdownLink.Smart, [new vscode.Range(0, 2, 0, 2)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('# title'), InsertMarkdownLink.Smart, [new vscode.Range(0, 2, 0, 2)], noopToken), true); }); test('Smart should be enabled in lists', async () => { assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('1. text'), PasteUrlAsMarkdownLink.Smart, [new vscode.Range(0, 3, 0, 3)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('1. text'), InsertMarkdownLink.Smart, [new vscode.Range(0, 3, 0, 3)], noopToken), true); }); test('Smart should be enabled in blockquotes', async () => { assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('> text'), PasteUrlAsMarkdownLink.Smart, [new vscode.Range(0, 3, 0, 3)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('> text'), InsertMarkdownLink.Smart, [new vscode.Range(0, 3, 0, 3)], noopToken), true); }); test('Smart should be disabled in indented code blocks', async () => { assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc(' code'), PasteUrlAsMarkdownLink.Smart, [new vscode.Range(0, 4, 0, 4)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc(' code'), InsertMarkdownLink.Smart, [new vscode.Range(0, 4, 0, 4)], noopToken), false); }); test('Smart should be disabled in fenced code blocks', async () => { assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('```\r\n\r\n```'), PasteUrlAsMarkdownLink.Smart, [new vscode.Range(0, 5, 0, 5)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('```\r\n\r\n```'), InsertMarkdownLink.Smart, [new vscode.Range(0, 5, 0, 5)], noopToken), false); assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('~~~\r\n\r\n~~~'), PasteUrlAsMarkdownLink.Smart, [new vscode.Range(0, 5, 0, 5)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('~~~\r\n\r\n~~~'), InsertMarkdownLink.Smart, [new vscode.Range(0, 5, 0, 5)], noopToken), false); }); @@ -183,127 +184,127 @@ suite('createEditAddingLinksForUriList', () => { const engine = createNewMarkdownEngine(); (await engine.getEngine(undefined)).use(katex); assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(engine, makeTestDoc('$$\r\n\r\n$$'), PasteUrlAsMarkdownLink.Smart, [new vscode.Range(0, 5, 0, 5)], noopToken), + await shouldInsertMarkdownLinkByDefault(engine, makeTestDoc('$$\r\n\r\n$$'), InsertMarkdownLink.Smart, [new vscode.Range(0, 5, 0, 5)], noopToken), false); }); test('Smart should be disabled in link definitions', async () => { assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('[ref]: http://example.com'), PasteUrlAsMarkdownLink.Smart, [new vscode.Range(0, 4, 0, 6)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('[ref]: http://example.com'), InsertMarkdownLink.Smart, [new vscode.Range(0, 4, 0, 6)], noopToken), false); assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('[ref]: '), PasteUrlAsMarkdownLink.Smart, [new vscode.Range(0, 7, 0, 7)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('[ref]: '), InsertMarkdownLink.Smart, [new vscode.Range(0, 7, 0, 7)], noopToken), false); assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('[ref]: '), PasteUrlAsMarkdownLink.Smart, [new vscode.Range(0, 0, 0, 0)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('[ref]: '), InsertMarkdownLink.Smart, [new vscode.Range(0, 0, 0, 0)], noopToken), false); }); test('Smart should be disabled in html blocks', async () => { assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('

\na\n

'), PasteUrlAsMarkdownLink.Smart, [new vscode.Range(1, 0, 1, 0)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('

\na\n

'), InsertMarkdownLink.Smart, [new vscode.Range(1, 0, 1, 0)], noopToken), false); }); test('Smart should be disabled in html blocks where paste creates the block', async () => { assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('

\n\n

'), PasteUrlAsMarkdownLink.Smart, [new vscode.Range(1, 0, 1, 0)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('

\n\n

'), InsertMarkdownLink.Smart, [new vscode.Range(1, 0, 1, 0)], noopToken), false, 'Between two html tags should be treated as html block'); assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('

\n\ntext'), PasteUrlAsMarkdownLink.Smart, [new vscode.Range(1, 0, 1, 0)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('

\n\ntext'), InsertMarkdownLink.Smart, [new vscode.Range(1, 0, 1, 0)], noopToken), false, 'Between opening html tag and text should be treated as html block'); assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('

\n\n\n

'), PasteUrlAsMarkdownLink.Smart, [new vscode.Range(1, 0, 1, 0)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('

\n\n\n

'), InsertMarkdownLink.Smart, [new vscode.Range(1, 0, 1, 0)], noopToken), true, 'Extra new line after paste should not be treated as html block'); }); test('Smart should be disabled in Markdown links', async () => { assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('[a](bcdef)'), PasteUrlAsMarkdownLink.Smart, [new vscode.Range(0, 4, 0, 6)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('[a](bcdef)'), InsertMarkdownLink.Smart, [new vscode.Range(0, 4, 0, 6)], noopToken), false); }); test('Smart should be disabled in Markdown images', async () => { assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('![a](bcdef)'), PasteUrlAsMarkdownLink.Smart, [new vscode.Range(0, 5, 0, 10)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('![a](bcdef)'), InsertMarkdownLink.Smart, [new vscode.Range(0, 5, 0, 10)], noopToken), false); }); test('Smart should be disabled in inline code', async () => { assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('``'), PasteUrlAsMarkdownLink.Smart, [new vscode.Range(0, 1, 0, 1)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('``'), InsertMarkdownLink.Smart, [new vscode.Range(0, 1, 0, 1)], noopToken), false, 'Should be disabled inside of inline code'); assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('``'), PasteUrlAsMarkdownLink.Smart, [new vscode.Range(0, 0, 0, 0)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('``'), InsertMarkdownLink.Smart, [new vscode.Range(0, 0, 0, 0)], noopToken), true, 'Should be enabled when cursor is outside but next to inline code'); assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('`a`'), PasteUrlAsMarkdownLink.Smart, [new vscode.Range(0, 3, 0, 3)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('`a`'), InsertMarkdownLink.Smart, [new vscode.Range(0, 3, 0, 3)], noopToken), true, 'Should be enabled when cursor is outside but next to inline code'); }); test('Smart should be enabled when pasting over inline code ', async () => { assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('`xyz`'), PasteUrlAsMarkdownLink.Smart, [new vscode.Range(0, 0, 0, 5)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('`xyz`'), InsertMarkdownLink.Smart, [new vscode.Range(0, 0, 0, 5)], noopToken), true); }); test('Smart should be disabled in inline math', async () => { assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('$$'), PasteUrlAsMarkdownLink.SmartWithSelection, [new vscode.Range(0, 1, 0, 1)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('$$'), InsertMarkdownLink.SmartWithSelection, [new vscode.Range(0, 1, 0, 1)], noopToken), false); }); test('Smart should be enabled for empty selection', async () => { assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('xyz'), PasteUrlAsMarkdownLink.Smart, [new vscode.Range(0, 0, 0, 0)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('xyz'), InsertMarkdownLink.Smart, [new vscode.Range(0, 0, 0, 0)], noopToken), true); }); test('SmartWithSelection should disable for empty selection', async () => { assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('xyz'), PasteUrlAsMarkdownLink.SmartWithSelection, [new vscode.Range(0, 0, 0, 0)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('xyz'), InsertMarkdownLink.SmartWithSelection, [new vscode.Range(0, 0, 0, 0)], noopToken), false); }); test('Smart should disable for selected link', async () => { assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('https://www.microsoft.com'), PasteUrlAsMarkdownLink.SmartWithSelection, [new vscode.Range(0, 0, 0, 25)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('https://www.microsoft.com'), InsertMarkdownLink.SmartWithSelection, [new vscode.Range(0, 0, 0, 25)], noopToken), false); }); test('Smart should disable for selected link with trailing whitespace', async () => { assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc(' https://www.microsoft.com '), PasteUrlAsMarkdownLink.SmartWithSelection, [new vscode.Range(0, 0, 0, 30)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc(' https://www.microsoft.com '), InsertMarkdownLink.SmartWithSelection, [new vscode.Range(0, 0, 0, 30)], noopToken), false); }); test('Should evaluate pasteAsMarkdownLink as true for a link pasted in square brackets', async () => { assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('[abc]'), PasteUrlAsMarkdownLink.SmartWithSelection, [new vscode.Range(0, 1, 0, 4)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('[abc]'), InsertMarkdownLink.SmartWithSelection, [new vscode.Range(0, 1, 0, 4)], noopToken), true); }); test('Should evaluate pasteAsMarkdownLink as false for selected whitespace and new lines', async () => { assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc(' \r\n\r\n'), PasteUrlAsMarkdownLink.SmartWithSelection, [new vscode.Range(0, 0, 0, 7)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc(' \r\n\r\n'), InsertMarkdownLink.SmartWithSelection, [new vscode.Range(0, 0, 0, 7)], noopToken), false); }); test('Smart should be disabled inside of autolinks', async () => { assert.strictEqual( - await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('<>'), PasteUrlAsMarkdownLink.Smart, [new vscode.Range(0, 1, 0, 1)], noopToken), + await shouldInsertMarkdownLinkByDefault(createNewMarkdownEngine(), makeTestDoc('<>'), InsertMarkdownLink.Smart, [new vscode.Range(0, 1, 0, 1)], noopToken), false); }); }); diff --git a/extensions/markdown-language-features/src/util/uriList.ts b/extensions/markdown-language-features/src/util/uriList.ts index 04897af453e..8b7f52e568f 100644 --- a/extensions/markdown-language-features/src/util/uriList.ts +++ b/extensions/markdown-language-features/src/util/uriList.ts @@ -3,12 +3,33 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { coalesce } from './arrays'; +import * as vscode from 'vscode'; + function splitUriList(str: string): string[] { return str.split('\r\n'); } -export function parseUriList(str: string): string[] { +function parseUriList(str: string): string[] { return splitUriList(str) .filter(value => !value.startsWith('#')) // Remove comments .map(value => value.trim()); } + +export class UriList { + + static from(str: string): UriList { + return new UriList(coalesce(parseUriList(str).map(line => { + try { + return { uri: vscode.Uri.parse(line), str: line }; + } catch { + // Uri parse failure + return undefined; + } + }))); + } + + private constructor( + public readonly entries: ReadonlyArray<{ readonly uri: vscode.Uri; readonly str: string }> + ) { } +}