diff --git a/extensions/markdown-language-features/src/features/smartSelect.ts b/extensions/markdown-language-features/src/features/smartSelect.ts index 2fd483edf7d..0fca6a5483c 100644 --- a/extensions/markdown-language-features/src/features/smartSelect.ts +++ b/extensions/markdown-language-features/src/features/smartSelect.ts @@ -23,14 +23,18 @@ export default class MarkdownSmartSelect implements vscode.SelectionRangeProvide private async provideSelectionRange(document: vscode.TextDocument, position: vscode.Position, _token: vscode.CancellationToken): Promise { const headerRange = await this.getHeaderSelectionRange(document, position); const blockRange = await this.getBlockSelectionRange(document, position, headerRange); - return blockRange || headerRange; + const inlineRange = await this.getInlineSelectionRange(document, position, blockRange); + return inlineRange || blockRange || headerRange; + } + private async getInlineSelectionRange(document: vscode.TextDocument, position: vscode.Position, blockRange?: vscode.SelectionRange): Promise { + return createInlineRange(document, position, blockRange); } private async getBlockSelectionRange(document: vscode.TextDocument, position: vscode.Position, headerRange?: vscode.SelectionRange): Promise { const tokens = await this.engine.parse(document); - const blockTokens = getTokensForPosition(tokens, position); + const blockTokens = getBlockTokensForPosition(tokens, position); if (blockTokens.length === 0) { return undefined; @@ -92,7 +96,7 @@ function createHeaderRange(header: TocEntry, isClosestHeaderToPosition: boolean, } } -function getTokensForPosition(tokens: Token[], position: vscode.Position): Token[] { +function getBlockTokensForPosition(tokens: Token[], position: vscode.Position): Token[] { const enclosingTokens = tokens.filter(token => token.map && (token.map[0] <= position.line && token.map[1] > position.line) && isBlockElement(token)); if (enclosingTokens.length === 0) { return []; @@ -123,6 +127,15 @@ function createBlockRange(block: Token, document: vscode.TextDocument, cursorLin } } +function createInlineRange(document: vscode.TextDocument, cursorPosition: vscode.Position, parent?: vscode.SelectionRange): vscode.SelectionRange | undefined { + const lineText = document.lineAt(cursorPosition.line).text; + const boldSelection = createBoldRange(lineText, cursorPosition.character, cursorPosition.line, parent); + const italicSelection = createOtherInlineRange(lineText, cursorPosition.character, cursorPosition.line, true, parent); + const linkSelection = createLinkRange(lineText, cursorPosition.character, cursorPosition.line, boldSelection ? boldSelection : italicSelection || parent); + const inlineCodeBlockSelection = createOtherInlineRange(lineText, cursorPosition.character, cursorPosition.line, false, linkSelection || parent); + return inlineCodeBlockSelection || linkSelection || boldSelection || italicSelection; +} + function createFencedRange(token: Token, cursorLine: number, document: vscode.TextDocument, parent?: vscode.SelectionRange): vscode.SelectionRange { const startLine = token.map[0]; const endLine = token.map[1] - 1; @@ -140,6 +153,91 @@ function createFencedRange(token: Token, cursorLine: number, document: vscode.Te } } +function createBoldRange(lineText: string, cursorChar: number, cursorLine: number, parent?: vscode.SelectionRange): vscode.SelectionRange | undefined { + // find closest ** that occurs before cursor position + let startBold = lineText.substring(0, cursorChar).lastIndexOf('**'); + + // find closest ** that occurs after the start ** + const endBoldIndex = lineText.substring(startBold + 2).indexOf('**'); + let endBold = startBold + 2 + lineText.substring(startBold + 2).indexOf('**'); + + if (startBold >= 0 && endBoldIndex >= 0 && startBold + 1 < endBold && startBold <= cursorChar && endBold >= cursorChar) { + const range = new vscode.Range(cursorLine, startBold, cursorLine, endBold + 2); + // **content cursor content** so select content then ** on both sides + const contentRange = new vscode.Range(cursorLine, startBold + 2, cursorLine, endBold); + return new vscode.SelectionRange(contentRange, new vscode.SelectionRange(range, parent)); + } else if (startBold >= 0) { + // **content**cursor or **content*cursor* + // find end ** from end of start ** to end of line (since the cursor is within the end stars) + let adjustedEnd = startBold + 2 + lineText.substring(startBold + 2).indexOf('**'); + startBold = lineText.substring(0, adjustedEnd - 2).lastIndexOf('**'); + if (adjustedEnd >= 0 && cursorChar === adjustedEnd || cursorChar === adjustedEnd + 1) { + if (lineText.charAt(adjustedEnd + 1) === '*') { + // *cursor* so need to extend end to include the second * + adjustedEnd += 1; + } + return new vscode.SelectionRange(new vscode.Range(cursorLine, startBold, cursorLine, adjustedEnd + 1), parent); + } + } else if (endBold > 0) { + // cursor**content** or *cursor*content** + // find start ** from start of string to cursor + 2 (since the cursor is within the start stars) + const adjustedStart = lineText.substring(0, cursorChar + 2).lastIndexOf('**'); + endBold = adjustedStart + 2 + lineText.substring(adjustedStart + 2).indexOf('**'); + if (adjustedStart >= 0 && adjustedStart === cursorChar || adjustedStart === cursorChar - 1) { + return new vscode.SelectionRange(new vscode.Range(cursorLine, adjustedStart, cursorLine, endBold + 2), parent); + } + } + return undefined; +} + +function createOtherInlineRange(lineText: string, cursorChar: number, cursorLine: number, isItalic: boolean, parent?: vscode.SelectionRange): vscode.SelectionRange | undefined { + const type = isItalic ? '*' : '`'; + const start = lineText.substring(0, cursorChar + 1).lastIndexOf(type); + let end = lineText.substring(cursorChar).indexOf(type); + + if (start >= 0 && end >= 0) { + end += cursorChar; + // ensure there's no * or ` before end + const intermediate = lineText.substring(start + 1, end - 1).indexOf(type); + if (intermediate < 0) { + const range = new vscode.Range(cursorLine, start, cursorLine, end + 1); + if (cursorChar > start && cursorChar <= end) { + // within the content so select content then include the stars or backticks + const contentRange = new vscode.Range(cursorLine, start + 1, cursorLine, end); + return new vscode.SelectionRange(contentRange, new vscode.SelectionRange(range, parent)); + } else if (cursorChar === start) { + return new vscode.SelectionRange(range, parent); + } + } + } + return undefined; +} + +function createLinkRange(lineText: string, cursorChar: number, cursorLine: number, parent?: vscode.SelectionRange): vscode.SelectionRange | undefined { + const regex = /(\[[^\(\)]*\])(\([^\[\]]*\))/g; + const matches = [...lineText.matchAll(regex)].filter(match => lineText.indexOf(match[0]) <= cursorChar && lineText.indexOf(match[0]) + match[0].length > cursorChar); + + if (matches.length > 0) { + // should only be one match, so select first and index 0 contains the entire match, so match = [text](url) + const link = matches[0][0]; + const linkRange = new vscode.SelectionRange(new vscode.Range(cursorLine, lineText.indexOf(link), cursorLine, lineText.indexOf(link) + link.length), parent); + + const linkText = matches[0][1]; + const url = matches[0][2]; + + // determine if cursor is within [text] or (url) in order to know which should be selected + const nearestType = cursorChar >= lineText.indexOf(linkText) && cursorChar < lineText.indexOf(linkText) + linkText.length ? linkText : url; + + // determine if cursor is on a bracket or paren and if so, return the [content] or (content), skipping over the content range + const cursorOnType = cursorChar === lineText.indexOf(nearestType) || cursorChar === lineText.indexOf(nearestType) + nearestType.length; + + const contentAndNearestType = new vscode.SelectionRange(new vscode.Range(cursorLine, lineText.indexOf(nearestType), cursorLine, lineText.indexOf(nearestType) + nearestType.length), linkRange); + const content = new vscode.SelectionRange(new vscode.Range(cursorLine, lineText.indexOf(nearestType) + 1, cursorLine, lineText.indexOf(nearestType) + nearestType.length - 1), contentAndNearestType); + return cursorOnType ? contentAndNearestType : content; + } + return undefined; +} + function isList(token: Token): boolean { return token.type ? ['ordered_list_open', 'list_item_open', 'bullet_list_open'].includes(token.type) : false; } diff --git a/extensions/markdown-language-features/src/test/smartSelect.test.ts b/extensions/markdown-language-features/src/test/smartSelect.test.ts index df2efba5e03..287a1aa211a 100644 --- a/extensions/markdown-language-features/src/test/smartSelect.test.ts +++ b/extensions/markdown-language-features/src/test/smartSelect.test.ts @@ -416,6 +416,157 @@ suite('markdown.SmartSelect', () => { `- level ${CURSOR}1`)); assertNestedLineNumbersEqual(ranges![0], [3, 3], [0, 3]); }); + test('Smart select without multiple ranges', async () => { + const ranges = await getSelectionRangesForDocument( + joinLines( + `# main header 1`, + ``, + ``, + `- ${CURSOR}paragraph`, + `- content`)); + + assertNestedLineNumbersEqual(ranges![0], [3, 3], [3, 4], [1, 4], [0, 4]); + }); + test('Smart select on second level of a list', async () => { + const ranges = await getSelectionRangesForDocument( + joinLines( + `* level 0`, + ` * level 1`, + ` * level 1`, + ` * level 2`, + ` * level 1`, + ` * level ${CURSOR}1`, + `* level 0`)); + + assertNestedLineNumbersEqual(ranges![0], [5, 5], [1, 5], [0, 5], [0, 6]); + }); + test('Smart select on third level of a list', async () => { + const ranges = await getSelectionRangesForDocument( + joinLines( + `* level 0`, + ` * level 1`, + ` * level 1`, + ` * level ${CURSOR}2`, + ` * level 2`, + ` * level 1`, + ` * level 1`, + `* level 0`)); + assertNestedLineNumbersEqual(ranges![0], [3, 3], [3, 4], [2, 4], [1, 6], [0, 6], [0, 7]); + }); + test('Smart select level 2 then level 1', async () => { + const ranges = await getSelectionRangesForDocument( + joinLines( + `* level 1`, + ` * level ${CURSOR}2`, + ` * level 2`, + `* level 1`)); + assertNestedLineNumbersEqual(ranges![0], [1, 1], [1, 2], [0, 2], [0, 3]); + }); + test('Smart select bold', async () => { + const ranges = await getSelectionRangesForDocument( + joinLines( + `stuff here **new${CURSOR}item** and here` + )); + assertNestedRangesEqual(ranges![0], [0, 13, 0, 30], [0, 11, 0, 32], [0, 0, 0, 41]); + }); + test('Smart select link', async () => { + const ranges = await getSelectionRangesForDocument( + joinLines( + `stuff here [text](https${CURSOR}://google.com) and here` + )); + assertNestedRangesEqual(ranges![0], [0, 18, 0, 46], [0, 17, 0, 47], [0, 11, 0, 47], [0, 0, 0, 56]); + }); + test('Smart select brackets', async () => { + const ranges = await getSelectionRangesForDocument( + joinLines( + `stuff here [te${CURSOR}xt](https://google.com) and here` + )); + assertNestedRangesEqual(ranges![0], [0, 12, 0, 26], [0, 11, 0, 27], [0, 11, 0, 47], [0, 0, 0, 56]); + }); + test('Smart select brackets under header in list', async () => { + const ranges = await getSelectionRangesForDocument( + joinLines( + `# main header 1`, + ``, + `- list`, + `paragraph`, + `## sub header`, + `- list`, + `- stuff here [te${CURSOR}xt](https://google.com) and here`, + `- list` + )); + assertNestedRangesEqual(ranges![0], [6, 14, 6, 28], [6, 13, 6, 29], [6, 13, 6, 49], [6, 0, 6, 59], [5, 0, 7, 6], [4, 0, 7, 6], [1, 0, 7, 6], [0, 0, 7, 6]); + }); + test('Smart select link under header in list', async () => { + const ranges = await getSelectionRangesForDocument( + joinLines( + `# main header 1`, + ``, + `- list`, + `paragraph`, + `## sub header`, + `- list`, + `- stuff here [text](${CURSOR}https://google.com) and here`, + `- list` + )); + assertNestedRangesEqual(ranges![0], [6, 20, 6, 48], [6, 19, 6, 49], [6, 13, 6, 49], [6, 0, 6, 59], [5, 0, 7, 6], [4, 0, 7, 6], [1, 0, 7, 6], [0, 0, 7, 6]); + }); + test('Smart select bold within list where multiple bold elements exists', async () => { + const ranges = await getSelectionRangesForDocument( + joinLines( + `# main header 1`, + ``, + `- list`, + `paragraph`, + `## sub header`, + `- list`, + `- stuff here [text]**${CURSOR}items in here** and **here**`, + `- list` + )); + assertNestedRangesEqual(ranges![0], [6, 21, 6, 44], [6, 19, 6, 46], [6, 0, 6, 60], [5, 0, 7, 6], [4, 0, 7, 6], [1, 0, 7, 6], [0, 0, 7, 6]); + }); + test('Smart select link in paragraph with multiple links', async () => { + const ranges = await getSelectionRangesForDocument( + joinLines( + `This[extension](https://marketplace.visualstudio.com/items?itemName=meganrogge.template-string-converter) addresses this [requ${CURSOR}est](https://github.com/microsoft/vscode/issues/56704) to convert Javascript/Typescript quotes to backticks when has been entered within a string.` + )); + assertNestedRangesEqual(ranges![0], [0, 123, 0, 140], [0, 122, 0, 141], [0, 122, 0, 191], [0, 0, 0, 283]); + }); + test('Smart select bold link', async () => { + const ranges = await getSelectionRangesForDocument( + joinLines( + `**[extens${CURSOR}ion](https://google.com)**` + )); + assertNestedRangesEqual(ranges![0], [0, 3, 0, 22], [0, 2, 0, 23], [0, 2, 0, 43], [0, 2, 0, 43], [0, 0, 0, 45], [0, 0, 0, 45]); + }); + test('Smart select inline code block', async () => { + const ranges = await getSelectionRangesForDocument( + joinLines( + `[\`code ${CURSOR} link\`]` + )); + assertNestedRangesEqual(ranges![0], [0, 2, 0, 22], [0, 1, 0, 23], [0, 0, 0, 24]); + }); + test('Smart select link with inline code block text', async () => { + const ranges = await getSelectionRangesForDocument( + joinLines( + `[\`code ${CURSOR} link\`](http://example.com)` + )); + assertNestedRangesEqual(ranges![0], [0, 2, 0, 22], [0, 1, 0, 23], [0, 1, 0, 23], [0, 0, 0, 24], [0, 0, 0, 44], [0, 0, 0, 44]); + }); + test('Smart select italic', async () => { + const ranges = await getSelectionRangesForDocument( + joinLines( + `*some nice ${CURSOR}text*` + )); + assertNestedRangesEqual(ranges![0], [0, 1, 0, 25], [0, 0, 0, 26], [0, 0, 0, 26]); + }); + test('Smart select italic link', async () => { + const ranges = await getSelectionRangesForDocument( + joinLines( + `*[extens${CURSOR}ion](https://google.com)*` + )); + assertNestedRangesEqual(ranges![0], [0, 2, 0, 21], [0, 1, 0, 22], [0, 1, 0, 42], [0, 1, 0, 42], [0, 0, 0, 43], [0, 0, 0, 43]); + }); }); function assertNestedLineNumbersEqual(range: vscode.SelectionRange, ...expectedRanges: [number, number][]) { @@ -426,6 +577,16 @@ function assertNestedLineNumbersEqual(range: vscode.SelectionRange, ...expectedR } } +function assertNestedRangesEqual(range: vscode.SelectionRange, ...expectedRanges: [number, number, number, number][]) { + const lineage = getLineage(range); + assert.strictEqual(lineage.length, expectedRanges.length, `expected depth: ${expectedRanges.length}, but was ${lineage.length} ${getValues(lineage)}`); + for (let i = 0; i < lineage.length; i++) { + assertLineNumbersEqual(lineage[i], expectedRanges[i][0], expectedRanges[i][2], `parent at a depth of ${i}`); + assert(lineage[i].range.start.character === expectedRanges[i][1], `parent at a depth of ${i} on start char`); + assert(lineage[i].range.end.character === expectedRanges[i][3], `parent at a depth of ${i} on end char`); + } +} + function getLineage(range: vscode.SelectionRange): vscode.SelectionRange[] { const result: vscode.SelectionRange[] = []; let currentRange: vscode.SelectionRange | undefined = range; @@ -436,6 +597,12 @@ function getLineage(range: vscode.SelectionRange): vscode.SelectionRange[] { return result; } +function getValues(ranges: vscode.SelectionRange[]): string[] { + return ranges.map(range => { + return range.range.start.line + ' ' + range.range.start.character + ' ' + range.range.end.line + ' ' + range.range.end.character; + }); +} + function assertLineNumbersEqual(selectionRange: vscode.SelectionRange, startLine: number, endLine: number, message: string) { assert.strictEqual(selectionRange.range.start.line, startLine, `failed on start line ${message}`); assert.strictEqual(selectionRange.range.end.line, endLine, `failed on end line ${message}`);