Skip to content

Commit 9fcfbdb

Browse files
authored
Merge pull request #209 from mjbvz/smart-select-link-titles
Add smart select for link titles
2 parents ff31a28 + 0a0eb26 commit 9fcfbdb

File tree

5 files changed

+178
-58
lines changed

5 files changed

+178
-58
lines changed

src/languageFeatures/documentLinks.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ function createMdLink(
6060
rawLink: string,
6161
matchIndex: number,
6262
fullMatch: string,
63+
titleMatch: string | undefined,
6364
workspace: IWorkspace,
6465
): MdLink | undefined {
6566
const isAngleBracketLink = rawLink.startsWith('<');
@@ -88,6 +89,17 @@ function createMdLink(
8889
const hrefEnd = document.positionAt(hrefStartOffset + link.length);
8990
const hrefRange: lsp.Range = { start: hrefStart, end: hrefEnd };
9091

92+
let titleRange: lsp.Range | undefined;
93+
if (titleMatch) {
94+
const indexOfTitleInLink = fullMatch.indexOf(titleMatch);
95+
if (indexOfTitleInLink >= 0) {
96+
const titleStartOffset = linkStartOffset + indexOfTitleInLink;
97+
titleRange = lsp.Range.create(
98+
document.positionAt(titleStartOffset),
99+
document.positionAt(titleStartOffset + titleMatch.length));
100+
}
101+
}
102+
91103
return {
92104
kind: MdLinkKind.Link,
93105
href: linkTarget,
@@ -99,6 +111,7 @@ function createMdLink(
99111
hrefRange,
100112
isAngleBracketLink,
101113
...getLinkSourceFragmentInfo(document, link, hrefStart, hrefEnd),
114+
titleRange,
102115
}
103116
};
104117
}
@@ -153,7 +166,7 @@ const linkPattern = new RegExp(
153166
/**/r`)` +
154167

155168
// Title
156-
/**/r`\s*(?:"[^"]*"|'[^']*'|\([^\(\)]*\))?\s*` +
169+
/**/r`\s*(?<title>"[^"]*"|'[^']*'|\([^\(\)]*\))?\s*` +
157170
r`\)`,
158171
'g');
159172

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

@@ -327,7 +340,7 @@ export class MdLinkComputer {
327340
const linkText = linkTextIncludingBrackets.slice(1, -1);
328341
const startOffset = (match.index ?? 0) + 1;
329342
for (const innerMatch of linkText.matchAll(linkPattern)) {
330-
const innerData = createMdLink(document, innerMatch[1], innerMatch[2], innerMatch[3], startOffset + (innerMatch.index ?? 0), innerMatch[0], this.#workspace);
343+
const innerData = createMdLink(document, innerMatch[1], innerMatch[2], innerMatch[3], startOffset + (innerMatch.index ?? 0), innerMatch[0], innerMatch.groups?.['title'], this.#workspace);
331344
if (innerData) {
332345
yield innerData;
333346
}
@@ -370,6 +383,7 @@ export class MdLinkComputer {
370383
hrefRange: hrefRange,
371384
range: { start: linkStart, end: linkEnd },
372385
...getLinkSourceFragmentInfo(document, link, hrefStart, hrefEnd),
386+
titleRange: undefined,
373387
}
374388
};
375389
}
@@ -462,6 +476,7 @@ export class MdLinkComputer {
462476
),
463477
hrefRange: lsp.Range.create(hrefStart, hrefEnd),
464478
hrefFragmentRange: undefined,
479+
titleRange: undefined, // TODO: support title
465480
},
466481
href: {
467482
kind: HrefKind.Reference,
@@ -510,6 +525,7 @@ export class MdLinkComputer {
510525
targetRange: hrefRange,
511526
hrefRange,
512527
...getLinkSourceFragmentInfo(document, rawLinkText, hrefStart, hrefEnd),
528+
titleRange: undefined, // TODO: support title
513529
},
514530
ref: { text: reference, range: refRange },
515531
href: target,
@@ -573,6 +589,7 @@ export class MdLinkComputer {
573589
hrefRange: hrefRange,
574590
range: { start: linkStart, end: linkEnd },
575591
...getLinkSourceFragmentInfo(document, link, linkStart, linkEnd),
592+
titleRange: undefined,
576593
}
577594
};
578595
}

src/languageFeatures/fileRename.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ export class MdFileRenameProvider {
116116
}
117117

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

242242
const newFilePath = removeNewUriExtIfNeeded(this.#config, link.href, newUri);
243-
const newLinkText = getLinkRenameText(this.#workspace, link.source, newFilePath, link.source.hrefPathText.startsWith('.'));
243+
const newLinkText = getLinkRenameText(this.#workspace, link.source, newFilePath, link.source.hrefText.startsWith('.'));
244244
if (typeof newLinkText === 'string') {
245245
const { range, newText } = getLinkRenameEdit(link, newLinkText);
246246
builder.replace(doc, range, newText);

src/languageFeatures/smartSelect.ts

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { getLine, ITextDocument } from '../types/textDocument';
1212
import { coalesce } from '../util/arrays';
1313
import { isEmptyOrWhitespace } from '../util/string';
1414
import { MdLinkProvider } from './documentLinks';
15-
import { HrefKind } from '../types/documentLink';
15+
import { HrefKind, MdLinkKind } from '../types/documentLink';
1616

1717
export class MdSelectionRangeProvider {
1818

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

253253
// determine if cursor is within [text] or (url) in order to know which should be selected
254254
if (rangeContains(link.source.targetRange, cursorPos)) {
255-
// Inside the href.
255+
// Inside the href
256+
257+
if (link.kind === MdLinkKind.Definition) {
258+
return makeSelectionRange(link.source.targetRange, fullLinkSelectionRange);
259+
}
260+
256261
// Create two ranges, one for the href content and one for the content plus brackets
257-
return makeSelectionRange(
262+
const linkDestRanges = makeSelectionRange(
258263
lsp.Range.create(
259264
translatePosition(link.source.targetRange.start, { characterDelta: 1 }),
260265
translatePosition(link.source.targetRange.end, { characterDelta: -1 }),
261266
),
262267
makeSelectionRange(link.source.targetRange, fullLinkSelectionRange));
268+
269+
if (link.source.titleRange) {
270+
// If we're inside the title, create a range for the title
271+
if (rangeContains(link.source.titleRange, cursorPos)) {
272+
return makeSelectionRange(link.source.titleRange, linkDestRanges);
273+
}
274+
}
275+
276+
if (link.source.isAngleBracketLink) {
277+
// If we're inside an angle bracket link, create a range for the contents of the bracket and the brackets
278+
if (rangeContains(link.source.hrefRange, cursorPos)) {
279+
return makeSelectionRange(
280+
link.source.hrefRange,
281+
makeSelectionRange(
282+
lsp.Range.create(
283+
translatePosition(link.source.hrefRange.start, { characterDelta: -1 }),
284+
translatePosition(link.source.hrefRange.end, { characterDelta: 1 }),
285+
),
286+
linkDestRanges));
287+
}
288+
}
289+
290+
if (link.source.titleRange) {
291+
// If we have a title but are not inside it, create an extra range just for the href too without the title
292+
return makeSelectionRange(link.source.hrefRange, linkDestRanges);
293+
}
294+
295+
return linkDestRanges;
263296
} else {
264297
// Inside the text
298+
299+
if (link.kind === MdLinkKind.Definition) {
300+
return makeSelectionRange(
301+
lsp.Range.create(
302+
translatePosition(link.source.range.start, { characterDelta: 1 }),
303+
translatePosition(link.source.targetRange.start, { characterDelta: -3 }), // TODO: Compute actual offset in cases where there is extra whitespace
304+
),
305+
fullLinkSelectionRange);
306+
}
307+
265308
return makeSelectionRange(
266309
lsp.Range.create(
267310
translatePosition(link.source.range.start, { characterDelta: 1 }),

src/test/smartSelect.test.ts

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,43 @@ suite('Smart select', () => {
601601
assertNestedRangesEqual(ranges![0], [0, 7, 0, 21], [0, 6, 0, 43], [0, 0, 0, 43]);
602602
});
603603

604+
test('Smart select of link title', async () => {
605+
const ranges = await getSelectionRangesForDocument(joinLines(
606+
`a [text](https://example.com "a${CURSOR}title") b`
607+
));
608+
assertNestedRangesEqual(ranges![0], [0, 29, 0, 47], [0, 9, 0, 47], [0, 8, 0, 48], [0, 2, 0, 48], [0, 0, 0, 50]);
609+
});
610+
611+
test('Smart select of link with title should have extra stop with just href', async () => {
612+
const ranges = await getSelectionRangesForDocument(joinLines(
613+
`a [text](https${CURSOR}://example.com "atitle") b`
614+
));
615+
assertNestedRangesEqual(ranges![0], [0, 9, 0, 38], [0, 9, 0, 47], [0, 8, 0, 48], [0, 2, 0, 48], [0, 0, 0, 50]);
616+
});
617+
618+
test('Smart select of angle bracket link should create stops within angle bracket', async () => {
619+
{
620+
const ranges = await getSelectionRangesForDocument(joinLines(
621+
`a [text](<file ${CURSOR}path>) b`
622+
));
623+
assertNestedRangesEqual(ranges![0], [0, 10, 0, 29], [0, 9, 0, 30], [0, 8, 0, 31], [0, 2, 0, 31], [0, 0, 0, 33]);
624+
}
625+
{
626+
// Cursor outside of angle brackets
627+
const ranges = await getSelectionRangesForDocument(joinLines(
628+
`a [text](<file path>) b`
629+
), [{ line: 0, character: 9 }]);
630+
assertNestedRangesEqual(ranges![0], [0, 9, 0, 20], [0, 8, 0, 21], [0, 2, 0, 21], [0, 0, 0, 23]);
631+
}
632+
{
633+
// With title
634+
const ranges = await getSelectionRangesForDocument(joinLines(
635+
`a [text](<file ${CURSOR}path> "title") b`
636+
));
637+
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]);
638+
}
639+
});
640+
604641
test('Smart select italic', async () => {
605642
const ranges = await getSelectionRangesForDocument(joinLines(
606643
`*some nice ${CURSOR}text*`
@@ -672,20 +709,34 @@ suite('Smart select', () => {
672709
);
673710
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]);
674711
});
712+
713+
test('Smart select of link definition in ref name', async () => {
714+
const ranges = await getSelectionRangesForDocument(joinLines(
715+
`[a${CURSOR}]: http://example.com`
716+
));
717+
assertNestedRangesEqual(ranges![0], [0, 1, 0, 12], [0, 0, 0, 33]);
718+
});
719+
720+
test('Smart select of link definition in target', async () => {
721+
const ranges = await getSelectionRangesForDocument(joinLines(
722+
`[a]: http${CURSOR}://example.com`
723+
));
724+
assertNestedRangesEqual(ranges![0], [0, 5, 0, 33], [0, 0, 0, 33]);
725+
});
675726
});
676727

677728

678729
function assertNestedLineNumbersEqual(range: lsp.SelectionRange, ...expectedRanges: [number, number][]) {
679730
const lineage = getLineage(range);
680-
assert.strictEqual(lineage.length, expectedRanges.length, `expected length: ${expectedRanges.length}, but was length: ${lineage.length} values: ${getValues(lineage)}`);
731+
assert.strictEqual(lineage.length, expectedRanges.length, `expected length: ${expectedRanges.length}, but was length: ${lineage.length}. Values: ${getValues(lineage)}`);
681732
for (let i = 0; i < lineage.length; i++) {
682733
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}`);
683734
}
684735
}
685736

686737
function assertNestedRangesEqual(range: lsp.SelectionRange, ...expectedRanges: [number, number, number, number][]) {
687738
const lineage = getLineage(range);
688-
assert.strictEqual(lineage.length, expectedRanges.length, `expected depth: ${expectedRanges.length}, but was length: ${lineage.length}) values: ${getValues(lineage)}`);
739+
assert.strictEqual(lineage.length, expectedRanges.length, `expected depth: ${expectedRanges.length}, but was length: ${lineage.length}. Values: ${getValues(lineage)}`);
689740
for (let i = 0; i < lineage.length; i++) {
690741
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}`);
691742
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}`);
@@ -703,10 +754,12 @@ function getLineage(range: lsp.SelectionRange): lsp.SelectionRange[] {
703754
return result;
704755
}
705756

706-
function getValues(ranges: lsp.SelectionRange[]): string[] {
707-
return ranges.map(range => {
708-
return `(${range.range.start.line}, ${range.range.start.character})-(${range.range.end.line}, ${range.range.end.character})`;
709-
});
757+
function getValues(ranges: lsp.SelectionRange[]): string {
758+
return ranges
759+
.map(range => {
760+
return `(${range.range.start.line}, ${range.range.start.character})-(${range.range.end.line}, ${range.range.end.character})`;
761+
})
762+
.join(' -> ');
710763
}
711764

712765
function assertLineNumbersEqual(selectionRange: lsp.SelectionRange, startLine: number, endLine: number, message: string) {

src/types/documentLink.ts

Lines changed: 51 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,13 @@ export interface MdLinkSource {
8181
readonly hrefFragmentRange: lsp.Range | undefined;
8282

8383
readonly isAngleBracketLink: boolean;
84+
85+
/**
86+
* The range of the link title if there is one
87+
*
88+
* For `[boris](/cat.md#siberian "title")` this would be `"title"`
89+
*/
90+
readonly titleRange: lsp.Range | undefined;
8491
}
8592

8693
export enum MdLinkKind {
@@ -123,25 +130,25 @@ export type MdLink = MdInlineLink | MdLinkDefinition | MdAutoLink;
123130
* A map that lets you look up definitions by reference name.
124131
*/
125132
export class LinkDefinitionSet implements Iterable<MdLinkDefinition> {
126-
readonly #map = new ReferenceLinkMap<MdLinkDefinition>();
127-
128-
constructor(links: Iterable<MdLink>) {
129-
for (const link of links) {
130-
if (link.kind === MdLinkKind.Definition) {
131-
if (!this.#map.has(link.ref.text)) {
132-
this.#map.set(link.ref.text, link);
133-
}
134-
}
135-
}
136-
}
137-
138-
public [Symbol.iterator](): Iterator<MdLinkDefinition> {
139-
return this.#map[Symbol.iterator]();
140-
}
141-
142-
public lookup(ref: string): MdLinkDefinition | undefined {
143-
return this.#map.lookup(ref);
144-
}
133+
readonly #map = new ReferenceLinkMap<MdLinkDefinition>();
134+
135+
constructor(links: Iterable<MdLink>) {
136+
for (const link of links) {
137+
if (link.kind === MdLinkKind.Definition) {
138+
if (!this.#map.has(link.ref.text)) {
139+
this.#map.set(link.ref.text, link);
140+
}
141+
}
142+
}
143+
}
144+
145+
public [Symbol.iterator](): Iterator<MdLinkDefinition> {
146+
return this.#map[Symbol.iterator]();
147+
}
148+
149+
public lookup(ref: string): MdLinkDefinition | undefined {
150+
return this.#map.lookup(ref);
151+
}
145152
}
146153

147154
/**
@@ -150,29 +157,29 @@ export class LinkDefinitionSet implements Iterable<MdLinkDefinition> {
150157
* Correctly normalizes reference names.
151158
*/
152159
export class ReferenceLinkMap<T> {
153-
readonly #map = new Map</* normalized ref */ string, T>();
154-
155-
public set(ref: string, link: T) {
156-
this.#map.set(this.#normalizeRefName(ref), link);
157-
}
158-
159-
public lookup(ref: string): T | undefined {
160-
return this.#map.get(this.#normalizeRefName(ref));
161-
}
162-
163-
public has(ref: string): boolean {
164-
return this.#map.has(this.#normalizeRefName(ref));
165-
}
166-
167-
public [Symbol.iterator](): Iterator<T> {
168-
return this.#map.values();
169-
}
170-
171-
/**
172-
* Normalizes a link reference. Link references are case-insensitive, so this lowercases the reference so you can
173-
* correctly compare two normalized references.
174-
*/
175-
#normalizeRefName(ref: string): string {
176-
return ref.normalize().trim().toLowerCase();
177-
}
160+
readonly #map = new Map</* normalized ref */ string, T>();
161+
162+
public set(ref: string, link: T) {
163+
this.#map.set(this.#normalizeRefName(ref), link);
164+
}
165+
166+
public lookup(ref: string): T | undefined {
167+
return this.#map.get(this.#normalizeRefName(ref));
168+
}
169+
170+
public has(ref: string): boolean {
171+
return this.#map.has(this.#normalizeRefName(ref));
172+
}
173+
174+
public [Symbol.iterator](): Iterator<T> {
175+
return this.#map.values();
176+
}
177+
178+
/**
179+
* Normalizes a link reference. Link references are case-insensitive, so this lowercases the reference so you can
180+
* correctly compare two normalized references.
181+
*/
182+
#normalizeRefName(ref: string): string {
183+
return ref.normalize().trim().toLowerCase();
184+
}
178185
}

0 commit comments

Comments
 (0)