Use header text as placeholder for md rename

For #146291
This commit is contained in:
Matt Bierner 2022-04-04 14:18:59 -07:00
parent f7d3a81b58
commit baa7434480
No known key found for this signature in database
GPG key ID: 099C331567E11888
6 changed files with 116 additions and 86 deletions

View file

@ -87,26 +87,25 @@ function getWorkspaceFolder(document: SkinnyTextDocument) {
|| vscode.workspace.workspaceFolders?.[0]?.uri;
}
interface MdLinkSource {
readonly text: string;
readonly resource: vscode.Uri;
readonly hrefRange: vscode.Range;
}
export interface MdInlineLink {
readonly kind: 'link';
readonly source: MdLinkSource;
readonly href: LinkHref;
readonly sourceText: string;
readonly sourceResource: vscode.Uri;
readonly sourceHrefRange: vscode.Range;
}
export interface MdLinkDefinition {
readonly kind: 'definition';
readonly sourceText: string;
readonly sourceResource: vscode.Uri;
readonly sourceHrefRange: vscode.Range;
readonly refRange: vscode.Range;
readonly ref: string;
readonly source: MdLinkSource;
readonly ref: {
readonly range: vscode.Range;
readonly text: string;
};
readonly href: ExternalHref | InternalHref;
}
@ -129,9 +128,11 @@ function extractDocumentLink(
return {
kind: 'link',
href: linkTarget,
sourceText: link,
sourceResource: document.uri,
sourceHrefRange: new vscode.Range(linkStart, linkEnd)
source: {
text: link,
resource: document.uri,
hrefRange: new vscode.Range(linkStart, linkEnd)
}
};
} catch {
return undefined;
@ -192,8 +193,8 @@ async function findCode(document: SkinnyTextDocument, engine: MarkdownEngine): P
}
function isLinkInsideCode(code: CodeInDocument, link: MdLink) {
return code.multiline.some(interval => link.sourceHrefRange.start.line >= interval[0] && link.sourceHrefRange.start.line < interval[1]) ||
code.inline.some(position => position.intersection(link.sourceHrefRange));
return code.multiline.some(interval => link.source.hrefRange.start.line >= interval[0] && link.source.hrefRange.start.line < interval[1]) ||
code.inline.some(position => position.intersection(link.source.hrefRange));
}
export class MdLinkProvider implements vscode.DocumentLinkProvider {
@ -219,11 +220,11 @@ export class MdLinkProvider implements vscode.DocumentLinkProvider {
private toValidDocumentLink(link: MdLink, definitionSet: LinkDefinitionSet): vscode.DocumentLink | undefined {
switch (link.href.kind) {
case 'external': {
return new vscode.DocumentLink(link.sourceHrefRange, link.href.uri);
return new vscode.DocumentLink(link.source.hrefRange, link.href.uri);
}
case 'internal': {
const uri = OpenDocumentLinkCommand.createCommandUri(link.sourceResource, link.href.path, link.href.fragment);
const documentLink = new vscode.DocumentLink(link.sourceHrefRange, uri);
const uri = OpenDocumentLinkCommand.createCommandUri(link.source.resource, link.href.path, link.href.fragment);
const documentLink = new vscode.DocumentLink(link.source.hrefRange, uri);
documentLink.tooltip = localize('documentLink.tooltip', 'Follow link');
return documentLink;
}
@ -231,8 +232,8 @@ export class MdLinkProvider implements vscode.DocumentLinkProvider {
const def = definitionSet.lookup(link.href.ref);
if (def) {
return new vscode.DocumentLink(
link.sourceHrefRange,
vscode.Uri.parse(`command:_markdown.moveCursorToPosition?${encodeURIComponent(JSON.stringify([def.sourceHrefRange.start.line, def.sourceHrefRange.start.character]))}`));
link.source.hrefRange,
vscode.Uri.parse(`command:_markdown.moveCursorToPosition?${encodeURIComponent(JSON.stringify([def.source.hrefRange.start.line, def.source.hrefRange.start.character]))}`));
} else {
return undefined;
}
@ -288,9 +289,11 @@ export class MdLinkProvider implements vscode.DocumentLinkProvider {
yield {
kind: 'link',
sourceText: reference,
sourceHrefRange: new vscode.Range(linkStart, linkEnd),
sourceResource: document.uri,
source: {
text: reference,
hrefRange: new vscode.Range(linkStart, linkEnd),
resource: document.uri,
},
href: {
kind: 'reference',
ref: reference,
@ -318,11 +321,12 @@ export class MdLinkProvider implements vscode.DocumentLinkProvider {
if (target) {
yield {
kind: 'definition',
sourceText: link,
sourceResource: document.uri,
sourceHrefRange: new vscode.Range(linkStart, linkEnd),
refRange,
ref: reference,
source: {
text: link,
resource: document.uri,
hrefRange: new vscode.Range(linkStart, linkEnd),
},
ref: { text: reference, range: refRange },
href: target,
};
}
@ -333,11 +337,12 @@ export class MdLinkProvider implements vscode.DocumentLinkProvider {
if (target) {
yield {
kind: 'definition',
sourceText: link,
sourceResource: document.uri,
sourceHrefRange: new vscode.Range(linkStart, linkEnd),
refRange,
ref: reference,
source: {
text: link,
resource: document.uri,
hrefRange: new vscode.Range(linkStart, linkEnd),
},
ref: { text: reference, range: refRange },
href: target,
};
}
@ -352,7 +357,7 @@ export class LinkDefinitionSet {
constructor(links: Iterable<MdLink>) {
for (const link of links) {
if (link.kind === 'definition') {
this._map.set(link.ref, link);
this._map.set(link.ref.text, link);
}
}
}

View file

@ -240,7 +240,7 @@ export class MdPathCompletionProvider implements vscode.CompletionItemProvider {
for (const def of definitions) {
yield {
kind: vscode.CompletionItemKind.Reference,
label: def.ref,
label: def.ref.text,
range: {
inserting: insertionRange,
replacing: replacementRange,

View file

@ -16,7 +16,7 @@ import { MdWorkspaceCache } from './workspaceCache';
/**
* A link in a markdown file.
*/
interface MdLinkReference {
export interface MdLinkReference {
readonly kind: 'link';
readonly isTriggerLocation: boolean;
readonly isDefinition: boolean;
@ -30,7 +30,7 @@ interface MdLinkReference {
/**
* A header in a markdown file.
*/
interface MdHeaderReference {
export interface MdHeaderReference {
readonly kind: 'header';
readonly isTriggerLocation: boolean;
@ -43,6 +43,13 @@ interface MdHeaderReference {
*/
readonly location: vscode.Location;
/**
* The text of the header.
*
* In `# a b c #` this would be `a b c`
*/
readonly headerText: string;
/**
* The range of the header text itself.
*
@ -55,12 +62,12 @@ export type MdReference = MdLinkReference | MdHeaderReference;
function getFragmentLocation(link: MdLink): vscode.Location | undefined {
const index = link.sourceText.indexOf('#');
const index = link.source.text.indexOf('#');
if (index < 0) {
return undefined;
}
return new vscode.Location(link.sourceResource, link.sourceHrefRange.with({
start: link.sourceHrefRange.start.translate({ characterDelta: index + 1 }),
return new vscode.Location(link.source.resource, link.source.hrefRange.with({
start: link.source.hrefRange.start.translate({ characterDelta: index + 1 }),
}));
}
@ -111,6 +118,7 @@ export class MdReferencesProvider extends Disposable implements vscode.Reference
isTriggerLocation: true,
isDefinition: true,
location: header.headerLocation,
headerText: header.text,
headerTextLocation: header.headerTextLocation
});
@ -124,7 +132,7 @@ export class MdReferencesProvider extends Disposable implements vscode.Reference
isTriggerLocation: false,
isDefinition: false,
link,
location: new vscode.Location(link.sourceResource, link.sourceHrefRange),
location: new vscode.Location(link.source.resource, link.source.hrefRange),
fragmentLocation: getFragmentLocation(link),
});
}
@ -139,13 +147,13 @@ export class MdReferencesProvider extends Disposable implements vscode.Reference
for (const link of docLinks) {
if (link.kind === 'definition') {
// We could be in either the ref name or the definition
if (link.refRange.contains(position)) {
return Array.from(this.getReferencesToLinkReference(docLinks, link.ref, { resource: document.uri, range: link.refRange }));
} else if (link.sourceHrefRange.contains(position)) {
if (link.ref.range.contains(position)) {
return Array.from(this.getReferencesToLinkReference(docLinks, link.ref.text, { resource: document.uri, range: link.ref.range }));
} else if (link.source.hrefRange.contains(position)) {
return this.getReferencesToLink(link);
}
} else {
if (link.sourceHrefRange.contains(position)) {
if (link.source.hrefRange.contains(position)) {
return this.getReferencesToLink(link);
}
}
@ -158,7 +166,7 @@ export class MdReferencesProvider extends Disposable implements vscode.Reference
const allLinksInWorkspace = (await this._linkCache.getAll()).flat();
if (sourceLink.href.kind === 'reference') {
return Array.from(this.getReferencesToLinkReference(allLinksInWorkspace, sourceLink.href.ref, { resource: sourceLink.sourceResource, range: sourceLink.sourceHrefRange }));
return Array.from(this.getReferencesToLinkReference(allLinksInWorkspace, sourceLink.href.ref, { resource: sourceLink.source.resource, range: sourceLink.source.hrefRange }));
}
if (sourceLink.href.kind !== 'internal') {
@ -189,6 +197,7 @@ export class MdReferencesProvider extends Disposable implements vscode.Reference
isTriggerLocation: false,
isDefinition: true,
location: entry.headerLocation,
headerText: entry.text,
headerTextLocation: entry.headerTextLocation
});
}
@ -203,7 +212,7 @@ export class MdReferencesProvider extends Disposable implements vscode.Reference
continue;
}
const isTriggerLocation = sourceLink.sourceResource.fsPath === link.sourceResource.fsPath && sourceLink.sourceHrefRange.isEqual(link.sourceHrefRange);
const isTriggerLocation = sourceLink.source.resource.fsPath === link.source.resource.fsPath && sourceLink.source.hrefRange.isEqual(link.source.hrefRange);
if (sourceLink.href.fragment) {
if (this.slugifier.fromHeading(link.href.fragment).equals(this.slugifier.fromHeading(sourceLink.href.fragment))) {
@ -212,20 +221,20 @@ export class MdReferencesProvider extends Disposable implements vscode.Reference
isTriggerLocation,
isDefinition: false,
link,
location: new vscode.Location(link.sourceResource, link.sourceHrefRange),
location: new vscode.Location(link.source.resource, link.source.hrefRange),
fragmentLocation: getFragmentLocation(link),
});
}
} else { // Triggered on a link without a fragment so we only require matching the file and ignore fragments
// But exclude cases where the file is implicitly referencing itself
if (!link.sourceText.startsWith('#') || link.sourceResource.fsPath !== targetDoc.uri.fsPath) {
if (!link.source.text.startsWith('#') || link.source.resource.fsPath !== targetDoc.uri.fsPath) {
references.push({
kind: 'link',
isTriggerLocation,
isDefinition: false,
link,
location: new vscode.Location(link.sourceResource, link.sourceHrefRange),
location: new vscode.Location(link.source.resource, link.source.hrefRange),
fragmentLocation: getFragmentLocation(link),
});
}
@ -244,22 +253,22 @@ export class MdReferencesProvider extends Disposable implements vscode.Reference
for (const link of allLinks) {
let ref: string;
if (link.kind === 'definition') {
ref = link.ref;
ref = link.ref.text;
} else if (link.href.kind === 'reference') {
ref = link.href.ref;
} else {
continue;
}
if (ref === refToFind && link.sourceResource.fsPath === from.resource.fsPath) {
const isTriggerLocation = from.resource.fsPath === link.sourceResource.fsPath && (
(link.href.kind === 'reference' && from.range.isEqual(link.sourceHrefRange)) || (link.kind === 'definition' && from.range.isEqual(link.refRange)));
if (ref === refToFind && link.source.resource.fsPath === from.resource.fsPath) {
const isTriggerLocation = from.resource.fsPath === link.source.resource.fsPath && (
(link.href.kind === 'reference' && from.range.isEqual(link.source.hrefRange)) || (link.kind === 'definition' && from.range.isEqual(link.ref.range)));
yield {
kind: 'link',
isTriggerLocation,
isDefinition: link.kind === 'definition',
link,
location: new vscode.Location(from.resource, link.sourceHrefRange),
location: new vscode.Location(from.resource, link.source.hrefRange),
fragmentLocation: getFragmentLocation(link),
};
}

View file

@ -7,7 +7,7 @@ import * as nls from 'vscode-nls';
import { Slugifier } from '../slugify';
import { Disposable } from '../util/dispose';
import { SkinnyTextDocument } from '../workspaceContents';
import { MdReference, MdReferencesProvider } from './references';
import { MdHeaderReference, MdReference, MdReferencesProvider } from './references';
const localize = nls.loadMessageBundle();
@ -28,7 +28,7 @@ export class MdRenameProvider extends Disposable implements vscode.RenameProvide
super();
}
public async prepareRename(document: SkinnyTextDocument, position: vscode.Position, token: vscode.CancellationToken): Promise<undefined | vscode.Range> {
public async prepareRename(document: SkinnyTextDocument, position: vscode.Position, token: vscode.CancellationToken): Promise<undefined | { readonly range: vscode.Range; readonly placeholder: string }> {
const references = await this.referencesProvider.getAllReferences(document, position, token);
if (token.isCancellationRequested) {
return undefined;
@ -45,28 +45,32 @@ export class MdRenameProvider extends Disposable implements vscode.RenameProvide
switch (triggerRef.kind) {
case 'header':
return triggerRef.headerTextLocation.range;
return { range: triggerRef.headerTextLocation.range, placeholder: triggerRef.headerText };
case 'link':
if (triggerRef.link.kind === 'definition') {
// We may have been triggered on the ref or the definition itself
if (triggerRef.link.refRange.contains(position)) {
return triggerRef.link.refRange;
} else {
if (triggerRef.fragmentLocation) {
return triggerRef.fragmentLocation.range;
}
throw new Error(localize('renameNoFiles', "Renaming files is currently not supported"));
if (triggerRef.link.ref.range.contains(position)) {
return { range: triggerRef.link.ref.range, placeholder: triggerRef.link.ref.text };
}
} else {
if (triggerRef.fragmentLocation) {
return triggerRef.fragmentLocation.range;
}
throw new Error(localize('renameNoFiles', "Renaming files is currently not supported"));
}
if (triggerRef.fragmentLocation) {
const declaration = this.findHeaderDeclaration(references);
if (declaration) {
return { range: triggerRef.fragmentLocation.range, placeholder: declaration.headerText };
}
return { range: triggerRef.fragmentLocation.range, placeholder: document.getText(triggerRef.fragmentLocation.range) };
}
throw new Error(localize('renameNoFiles', "Renaming files is currently not supported"));
}
}
private findHeaderDeclaration(references: readonly MdReference[]): MdHeaderReference | undefined {
return references.find(ref => ref.isDefinition && ref.kind === 'header') as MdHeaderReference | undefined;
}
public async provideRenameEdits(document: SkinnyTextDocument, position: vscode.Position, newName: string, token: vscode.CancellationToken): Promise<vscode.WorkspaceEdit | undefined> {
const references = await this.getAllReferences(document, position, token);
if (token.isCancellationRequested || !references?.length) {
@ -79,7 +83,7 @@ export class MdRenameProvider extends Disposable implements vscode.RenameProvide
}
const isRefRename = triggerRef.kind === 'link' && (
(triggerRef.link.kind === 'definition' && triggerRef.link.refRange.contains(position)) || triggerRef.link.href.kind === 'reference'
(triggerRef.link.kind === 'definition' && triggerRef.link.ref.range.contains(position)) || triggerRef.link.href.kind === 'reference'
);
const slug = this.slugifier.fromHeading(newName).value;
@ -94,9 +98,9 @@ export class MdRenameProvider extends Disposable implements vscode.RenameProvide
if (ref.link.kind === 'definition') {
// We may be renaming either the reference or the definition itself
if (isRefRename) {
edit.replace(ref.link.sourceResource, ref.link.refRange, newName);
edit.replace(ref.link.source.resource, ref.link.ref.range, newName);
} else {
edit.replace(ref.link.sourceResource, ref.fragmentLocation?.range ?? ref.link.sourceHrefRange, ref.fragmentLocation ? slug : newName);
edit.replace(ref.link.source.resource, ref.fragmentLocation?.range ?? ref.link.source.hrefRange, ref.fragmentLocation ? slug : newName);
}
} else {
edit.replace(ref.location.uri, ref.fragmentLocation?.range ?? ref.location.range, ref.link.href.kind === 'reference' ? newName : slug);

View file

@ -18,9 +18,9 @@ import { assertRangeEqual, joinLines, noopToken, workspacePath } from './util';
/**
* Get the range that the rename should happen on.
* Get prepare rename info.
*/
function getRenameRange(doc: InMemoryDocument, pos: vscode.Position, workspaceContents: MdWorkspaceContents) {
function prepareRename(doc: InMemoryDocument, pos: vscode.Position, workspaceContents: MdWorkspaceContents): Promise<undefined | { readonly range: vscode.Range; readonly placeholder: string }> {
const engine = createNewMarkdownEngine();
const linkProvider = new MdLinkProvider(engine);
const referencesProvider = new MdReferencesProvider(linkProvider, workspaceContents, engine, githubSlugifier);
@ -72,8 +72,8 @@ suite('markdown: rename', () => {
`# abc`
));
const range = await getRenameRange(doc, new vscode.Position(0, 0), new InMemoryWorkspaceMarkdownDocuments([doc]));
assertRangeEqual(range!, new vscode.Range(0, 2, 0, 5));
const info = await prepareRename(doc, new vscode.Position(0, 0), new InMemoryWorkspaceMarkdownDocuments([doc]));
assertRangeEqual(info!.range, new vscode.Range(0, 2, 0, 5));
const edit = await getRenameEdits(doc, new vscode.Position(0, 0), "New Header", new InMemoryWorkspaceMarkdownDocuments([doc]));
assertEditsEqual(edit!, {
@ -89,8 +89,8 @@ suite('markdown: rename', () => {
`### abc ###`
));
const range = await getRenameRange(doc, new vscode.Position(0, 0), new InMemoryWorkspaceMarkdownDocuments([doc]));
assertRangeEqual(range!, new vscode.Range(0, 4, 0, 7));
const info = await prepareRename(doc, new vscode.Position(0, 0), new InMemoryWorkspaceMarkdownDocuments([doc]));
assertRangeEqual(info!.range, new vscode.Range(0, 4, 0, 7));
const edit = await getRenameEdits(doc, new vscode.Position(0, 0), "New Header", new InMemoryWorkspaceMarkdownDocuments([doc]));
assertEditsEqual(edit!, {
@ -299,7 +299,7 @@ suite('markdown: rename', () => {
`[text](#header)`,
));
await assert.rejects(getRenameRange(doc, new vscode.Position(1, 2), new InMemoryWorkspaceMarkdownDocuments([doc])));
await assert.rejects(prepareRename(doc, new vscode.Position(1, 2), new InMemoryWorkspaceMarkdownDocuments([doc])));
});
test('Rename should not be supported on bare file link', async () => {
@ -309,7 +309,7 @@ suite('markdown: rename', () => {
`[other](./doc.md)`,
));
await assert.rejects(getRenameRange(doc, new vscode.Position(0, 10), new InMemoryWorkspaceMarkdownDocuments([doc])));
await assert.rejects(prepareRename(doc, new vscode.Position(0, 10), new InMemoryWorkspaceMarkdownDocuments([doc])));
});
test('Rename should not be supported on bare file link in definition', async () => {
@ -319,6 +319,18 @@ suite('markdown: rename', () => {
`[ref]: ./doc.md`,
));
await assert.rejects(getRenameRange(doc, new vscode.Position(1, 10), new InMemoryWorkspaceMarkdownDocuments([doc])));
await assert.rejects(prepareRename(doc, new vscode.Position(1, 10), new InMemoryWorkspaceMarkdownDocuments([doc])));
});
test('Rename on link should use header text as placeholder', async () => {
const uri = workspacePath('doc.md');
const doc = new InMemoryDocument(uri, joinLines(
`### a B c ###`,
`[text](#a-b-c)`,
));
const info = await prepareRename(doc, new vscode.Position(1, 10), new InMemoryWorkspaceMarkdownDocuments([doc]));
assert.strictEqual(info!.placeholder, 'a B c');
assertRangeEqual(info!.range, new vscode.Range(1, 8, 1, 13));
});
});

View file

@ -24,7 +24,7 @@ export interface SkinnyTextDocument {
readonly version: number;
readonly lineCount: number;
getText(): string;
getText(range?: vscode.Range): string;
lineAt(line: number): SkinnyTextLine;
positionAt(offset: number): vscode.Position;
}