Skip to content

Add smart select for link titles #209

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 19, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions src/languageFeatures/documentLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ function createMdLink(
rawLink: string,
matchIndex: number,
fullMatch: string,
titleMatch: string | undefined,
workspace: IWorkspace,
): MdLink | undefined {
const isAngleBracketLink = rawLink.startsWith('<');
Expand Down Expand Up @@ -88,6 +89,17 @@ function createMdLink(
const hrefEnd = document.positionAt(hrefStartOffset + link.length);
const hrefRange: lsp.Range = { start: hrefStart, end: hrefEnd };

let titleRange: lsp.Range | undefined;
if (titleMatch) {
const indexOfTitleInLink = fullMatch.indexOf(titleMatch);
if (indexOfTitleInLink >= 0) {
const titleStartOffset = linkStartOffset + indexOfTitleInLink;
titleRange = lsp.Range.create(
document.positionAt(titleStartOffset),
document.positionAt(titleStartOffset + titleMatch.length));
}
}

return {
kind: MdLinkKind.Link,
href: linkTarget,
Expand All @@ -99,6 +111,7 @@ function createMdLink(
hrefRange,
isAngleBracketLink,
...getLinkSourceFragmentInfo(document, link, hrefStart, hrefEnd),
titleRange,
}
};
}
Expand Down Expand Up @@ -153,7 +166,7 @@ const linkPattern = new RegExp(
/**/r`)` +

// Title
/**/r`\s*(?:"[^"]*"|'[^']*'|\([^\(\)]*\))?\s*` +
/**/r`\s*(?<title>"[^"]*"|'[^']*'|\([^\(\)]*\))?\s*` +
r`\)`,
'g');

Expand Down Expand Up @@ -318,7 +331,7 @@ export class MdLinkComputer {
const text = document.getText();
for (const match of text.matchAll(linkPattern)) {
const linkTextIncludingBrackets = match[1];
const matchLinkData = createMdLink(document, linkTextIncludingBrackets, match[2], match[3], match.index ?? 0, match[0], this.#workspace);
const matchLinkData = createMdLink(document, linkTextIncludingBrackets, match[2], match[3], match.index ?? 0, match[0], match.groups?.['title'], this.#workspace);
if (matchLinkData && !noLinkRanges.contains(matchLinkData.source.hrefRange.start)) {
yield matchLinkData;

Expand All @@ -327,7 +340,7 @@ export class MdLinkComputer {
const linkText = linkTextIncludingBrackets.slice(1, -1);
const startOffset = (match.index ?? 0) + 1;
for (const innerMatch of linkText.matchAll(linkPattern)) {
const innerData = createMdLink(document, innerMatch[1], innerMatch[2], innerMatch[3], startOffset + (innerMatch.index ?? 0), innerMatch[0], this.#workspace);
const innerData = createMdLink(document, innerMatch[1], innerMatch[2], innerMatch[3], startOffset + (innerMatch.index ?? 0), innerMatch[0], innerMatch.groups?.['title'], this.#workspace);
if (innerData) {
yield innerData;
}
Expand Down Expand Up @@ -370,6 +383,7 @@ export class MdLinkComputer {
hrefRange: hrefRange,
range: { start: linkStart, end: linkEnd },
...getLinkSourceFragmentInfo(document, link, hrefStart, hrefEnd),
titleRange: undefined,
}
};
}
Expand Down Expand Up @@ -462,6 +476,7 @@ export class MdLinkComputer {
),
hrefRange: lsp.Range.create(hrefStart, hrefEnd),
hrefFragmentRange: undefined,
titleRange: undefined, // TODO: support title
},
href: {
kind: HrefKind.Reference,
Expand Down Expand Up @@ -510,6 +525,7 @@ export class MdLinkComputer {
targetRange: hrefRange,
hrefRange,
...getLinkSourceFragmentInfo(document, rawLinkText, hrefStart, hrefEnd),
titleRange: undefined, // TODO: support title
},
ref: { text: reference, range: refRange },
href: target,
Expand Down Expand Up @@ -573,6 +589,7 @@ export class MdLinkComputer {
hrefRange: hrefRange,
range: { start: linkStart, end: linkEnd },
...getLinkSourceFragmentInfo(document, link, linkStart, linkEnd),
titleRange: undefined,
}
};
}
Expand Down
4 changes: 2 additions & 2 deletions src/languageFeatures/fileRename.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ export class MdFileRenameProvider {
}

// If the link was within a file in the moved dir but traversed out of it, we also need to update the path
if (link.source.hrefPathText.startsWith('..') && isParentDir(edit.newUri, docUri)) {
if (link.source.hrefText.startsWith('..') && isParentDir(edit.newUri, docUri)) {
// Resolve the link relative to the old file path
const oldDocUri = docUri.with({
path: Utils.joinPath(edit.oldUri, path.posix.relative(edit.newUri.path, docUri.path)).path
Expand Down Expand Up @@ -240,7 +240,7 @@ export class MdFileRenameProvider {
}

const newFilePath = removeNewUriExtIfNeeded(this.#config, link.href, newUri);
const newLinkText = getLinkRenameText(this.#workspace, link.source, newFilePath, link.source.hrefPathText.startsWith('.'));
const newLinkText = getLinkRenameText(this.#workspace, link.source, newFilePath, link.source.hrefText.startsWith('.'));
if (typeof newLinkText === 'string') {
const { range, newText } = getLinkRenameEdit(link, newLinkText);
builder.replace(doc, range, newText);
Expand Down
49 changes: 46 additions & 3 deletions src/languageFeatures/smartSelect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { getLine, ITextDocument } from '../types/textDocument';
import { coalesce } from '../util/arrays';
import { isEmptyOrWhitespace } from '../util/string';
import { MdLinkProvider } from './documentLinks';
import { HrefKind } from '../types/documentLink';
import { HrefKind, MdLinkKind } from '../types/documentLink';

export class MdSelectionRangeProvider {

Expand Down Expand Up @@ -252,16 +252,59 @@ async function createLinkRange(document: ITextDocument, cursorPos: lsp.Position,

// determine if cursor is within [text] or (url) in order to know which should be selected
if (rangeContains(link.source.targetRange, cursorPos)) {
// Inside the href.
// Inside the href

if (link.kind === MdLinkKind.Definition) {
return makeSelectionRange(link.source.targetRange, fullLinkSelectionRange);
}

// Create two ranges, one for the href content and one for the content plus brackets
return makeSelectionRange(
const linkDestRanges = makeSelectionRange(
lsp.Range.create(
translatePosition(link.source.targetRange.start, { characterDelta: 1 }),
translatePosition(link.source.targetRange.end, { characterDelta: -1 }),
),
makeSelectionRange(link.source.targetRange, fullLinkSelectionRange));

if (link.source.titleRange) {
// If we're inside the title, create a range for the title
if (rangeContains(link.source.titleRange, cursorPos)) {
return makeSelectionRange(link.source.titleRange, linkDestRanges);
}
}

if (link.source.isAngleBracketLink) {
// If we're inside an angle bracket link, create a range for the contents of the bracket and the brackets
if (rangeContains(link.source.hrefRange, cursorPos)) {
return makeSelectionRange(
link.source.hrefRange,
makeSelectionRange(
lsp.Range.create(
translatePosition(link.source.hrefRange.start, { characterDelta: -1 }),
translatePosition(link.source.hrefRange.end, { characterDelta: 1 }),
),
linkDestRanges));
}
}

if (link.source.titleRange) {
// If we have a title but are not inside it, create an extra range just for the href too without the title
return makeSelectionRange(link.source.hrefRange, linkDestRanges);
}

return linkDestRanges;
} else {
// Inside the text

if (link.kind === MdLinkKind.Definition) {
return makeSelectionRange(
lsp.Range.create(
translatePosition(link.source.range.start, { characterDelta: 1 }),
translatePosition(link.source.targetRange.start, { characterDelta: -3 }), // TODO: Compute actual offset in cases where there is extra whitespace
),
fullLinkSelectionRange);
}

return makeSelectionRange(
lsp.Range.create(
translatePosition(link.source.range.start, { characterDelta: 1 }),
Expand Down
65 changes: 59 additions & 6 deletions src/test/smartSelect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,43 @@ suite('Smart select', () => {
assertNestedRangesEqual(ranges![0], [0, 7, 0, 21], [0, 6, 0, 43], [0, 0, 0, 43]);
});

test('Smart select of link title', async () => {
const ranges = await getSelectionRangesForDocument(joinLines(
`a [text](https://example.com "a${CURSOR}title") b`
));
assertNestedRangesEqual(ranges![0], [0, 29, 0, 47], [0, 9, 0, 47], [0, 8, 0, 48], [0, 2, 0, 48], [0, 0, 0, 50]);
});

test('Smart select of link with title should have extra stop with just href', async () => {
const ranges = await getSelectionRangesForDocument(joinLines(
`a [text](https${CURSOR}://example.com "atitle") b`
));
assertNestedRangesEqual(ranges![0], [0, 9, 0, 38], [0, 9, 0, 47], [0, 8, 0, 48], [0, 2, 0, 48], [0, 0, 0, 50]);
});

test('Smart select of angle bracket link should create stops within angle bracket', async () => {
{
const ranges = await getSelectionRangesForDocument(joinLines(
`a [text](<file ${CURSOR}path>) b`
));
assertNestedRangesEqual(ranges![0], [0, 10, 0, 29], [0, 9, 0, 30], [0, 8, 0, 31], [0, 2, 0, 31], [0, 0, 0, 33]);
}
{
// Cursor outside of angle brackets
const ranges = await getSelectionRangesForDocument(joinLines(
`a [text](<file path>) b`
), [{ line: 0, character: 9 }]);
assertNestedRangesEqual(ranges![0], [0, 9, 0, 20], [0, 8, 0, 21], [0, 2, 0, 21], [0, 0, 0, 23]);
}
{
// With title
const ranges = await getSelectionRangesForDocument(joinLines(
`a [text](<file ${CURSOR}path> "title") b`
));
assertNestedRangesEqual(ranges![0], [0, 10, 0, 29], [0, 9, 0, 30], [0, 9, 0, 38], [0, 8, 0, 39], [0, 2, 0, 39], [0, 0, 0, 41]);
}
});

test('Smart select italic', async () => {
const ranges = await getSelectionRangesForDocument(joinLines(
`*some nice ${CURSOR}text*`
Expand Down Expand Up @@ -672,20 +709,34 @@ suite('Smart select', () => {
);
assertNestedRangesEqual(ranges![0], [27, 0, 27, 201], [26, 0, 29, 70], [25, 0, 29, 70], [24, 0, 29, 70], [23, 0, 29, 70], [10, 0, 29, 70], [9, 0, 29, 70]);
});

test('Smart select of link definition in ref name', async () => {
const ranges = await getSelectionRangesForDocument(joinLines(
`[a${CURSOR}]: http://example.com`
));
assertNestedRangesEqual(ranges![0], [0, 1, 0, 12], [0, 0, 0, 33]);
});

test('Smart select of link definition in target', async () => {
const ranges = await getSelectionRangesForDocument(joinLines(
`[a]: http${CURSOR}://example.com`
));
assertNestedRangesEqual(ranges![0], [0, 5, 0, 33], [0, 0, 0, 33]);
});
});


function assertNestedLineNumbersEqual(range: lsp.SelectionRange, ...expectedRanges: [number, number][]) {
const lineage = getLineage(range);
assert.strictEqual(lineage.length, expectedRanges.length, `expected length: ${expectedRanges.length}, but was length: ${lineage.length} values: ${getValues(lineage)}`);
assert.strictEqual(lineage.length, expectedRanges.length, `expected length: ${expectedRanges.length}, but was length: ${lineage.length}. Values: ${getValues(lineage)}`);
for (let i = 0; i < lineage.length; i++) {
assertLineNumbersEqual(lineage[i], expectedRanges[i][0], expectedRanges[i][1], `parent at a depth of ${i}. Expected: ${expectedRanges[i][0]} but was ${lineage[i].range.start.line}`);
}
}

function assertNestedRangesEqual(range: lsp.SelectionRange, ...expectedRanges: [number, number, number, number][]) {
const lineage = getLineage(range);
assert.strictEqual(lineage.length, expectedRanges.length, `expected depth: ${expectedRanges.length}, but was length: ${lineage.length}) values: ${getValues(lineage)}`);
assert.strictEqual(lineage.length, expectedRanges.length, `expected depth: ${expectedRanges.length}, but was length: ${lineage.length}. Values: ${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}. Expected: ${expectedRanges[i][0]} but was ${lineage[i].range.start.line}`);
assert(lineage[i].range.start.character === expectedRanges[i][1], `parent at a depth of ${i} on start char. Expected: ${expectedRanges[i][1]} but was ${lineage[i].range.start.character}`);
Expand All @@ -703,10 +754,12 @@ function getLineage(range: lsp.SelectionRange): lsp.SelectionRange[] {
return result;
}

function getValues(ranges: lsp.SelectionRange[]): string[] {
return ranges.map(range => {
return `(${range.range.start.line}, ${range.range.start.character})-(${range.range.end.line}, ${range.range.end.character})`;
});
function getValues(ranges: lsp.SelectionRange[]): string {
return ranges
.map(range => {
return `(${range.range.start.line}, ${range.range.start.character})-(${range.range.end.line}, ${range.range.end.character})`;
})
.join(' -> ');
}

function assertLineNumbersEqual(selectionRange: lsp.SelectionRange, startLine: number, endLine: number, message: string) {
Expand Down
95 changes: 51 additions & 44 deletions src/types/documentLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,13 @@ export interface MdLinkSource {
readonly hrefFragmentRange: lsp.Range | undefined;

readonly isAngleBracketLink: boolean;

/**
* The range of the link title if there is one
*
* For `[boris](/cat.md#siberian "title")` this would be `"title"`
*/
readonly titleRange: lsp.Range | undefined;
}

export enum MdLinkKind {
Expand Down Expand Up @@ -123,25 +130,25 @@ export type MdLink = MdInlineLink | MdLinkDefinition | MdAutoLink;
* A map that lets you look up definitions by reference name.
*/
export class LinkDefinitionSet implements Iterable<MdLinkDefinition> {
readonly #map = new ReferenceLinkMap<MdLinkDefinition>();

constructor(links: Iterable<MdLink>) {
for (const link of links) {
if (link.kind === MdLinkKind.Definition) {
if (!this.#map.has(link.ref.text)) {
this.#map.set(link.ref.text, link);
}
}
}
}

public [Symbol.iterator](): Iterator<MdLinkDefinition> {
return this.#map[Symbol.iterator]();
}

public lookup(ref: string): MdLinkDefinition | undefined {
return this.#map.lookup(ref);
}
readonly #map = new ReferenceLinkMap<MdLinkDefinition>();

constructor(links: Iterable<MdLink>) {
for (const link of links) {
if (link.kind === MdLinkKind.Definition) {
if (!this.#map.has(link.ref.text)) {
this.#map.set(link.ref.text, link);
}
}
}
}

public [Symbol.iterator](): Iterator<MdLinkDefinition> {
return this.#map[Symbol.iterator]();
}

public lookup(ref: string): MdLinkDefinition | undefined {
return this.#map.lookup(ref);
}
}

/**
Expand All @@ -150,29 +157,29 @@ export class LinkDefinitionSet implements Iterable<MdLinkDefinition> {
* Correctly normalizes reference names.
*/
export class ReferenceLinkMap<T> {
readonly #map = new Map</* normalized ref */ string, T>();

public set(ref: string, link: T) {
this.#map.set(this.#normalizeRefName(ref), link);
}

public lookup(ref: string): T | undefined {
return this.#map.get(this.#normalizeRefName(ref));
}

public has(ref: string): boolean {
return this.#map.has(this.#normalizeRefName(ref));
}

public [Symbol.iterator](): Iterator<T> {
return this.#map.values();
}

/**
* Normalizes a link reference. Link references are case-insensitive, so this lowercases the reference so you can
* correctly compare two normalized references.
*/
#normalizeRefName(ref: string): string {
return ref.normalize().trim().toLowerCase();
}
readonly #map = new Map</* normalized ref */ string, T>();

public set(ref: string, link: T) {
this.#map.set(this.#normalizeRefName(ref), link);
}

public lookup(ref: string): T | undefined {
return this.#map.get(this.#normalizeRefName(ref));
}

public has(ref: string): boolean {
return this.#map.has(this.#normalizeRefName(ref));
}

public [Symbol.iterator](): Iterator<T> {
return this.#map.values();
}

/**
* Normalizes a link reference. Link references are case-insensitive, so this lowercases the reference so you can
* correctly compare two normalized references.
*/
#normalizeRefName(ref: string): string {
return ref.normalize().trim().toLowerCase();
}
}