Add support for renaming files in markdown using F2

This lets you trigger F2 on a file path in a markdown link to both rename the file and also update all references to it
This commit is contained in:
Matt Bierner 2022-04-20 16:38:30 -07:00
parent 9fbd962973
commit 0ac39e800d
No known key found for this signature in database
GPG key ID: 099C331567E11888
5 changed files with 304 additions and 63 deletions

View file

@ -71,7 +71,7 @@ function registerMarkdownLanguageFeatures(
vscode.languages.registerSelectionRangeProvider(selector, new MdSmartSelect(engine)),
vscode.languages.registerWorkspaceSymbolProvider(new MdWorkspaceSymbolProvider(symbolProvider, workspaceContents)),
vscode.languages.registerReferenceProvider(selector, referencesProvider),
vscode.languages.registerRenameProvider(selector, new MdRenameProvider(referencesProvider, githubSlugifier)),
vscode.languages.registerRenameProvider(selector, new MdRenameProvider(referencesProvider, workspaceContents, githubSlugifier)),
MdPathCompletionProvider.register(selector, engine, linkProvider),
registerDropIntoEditor(selector),
registerFindFileReferences(commandManager, referencesProvider),

View file

@ -176,22 +176,14 @@ export class MdReferencesProvider extends Disposable implements vscode.Reference
return references;
}
let targetDoc = await this.workspaceContents.getMarkdownDocument(sourceLink.href.path);
if (!targetDoc) {
// We don't think the file exists. If it doesn't already have an extension, try tacking on a `.md` and using that instead
if (uri.Utils.extname(sourceLink.href.path) === '') {
const dotMdResource = sourceLink.href.path.with({ path: sourceLink.href.path.path + '.md' });
targetDoc = await this.workspaceContents.getMarkdownDocument(dotMdResource);
}
}
if (!targetDoc || token.isCancellationRequested) {
const targetDoc = await tryFindMdDocumentForLink(sourceLink.href, this.workspaceContents);
if (token.isCancellationRequested) {
return [];
}
const references: MdReference[] = [];
if (sourceLink.href.fragment && sourceLink.source.fragmentRange?.contains(triggerPosition)) {
if (targetDoc && sourceLink.href.fragment && sourceLink.source.fragmentRange?.contains(triggerPosition)) {
const toc = await TableOfContents.create(this.engine, targetDoc);
const entry = toc.lookup(sourceLink.href.fragment);
if (entry) {
@ -222,7 +214,7 @@ export class MdReferencesProvider extends Disposable implements vscode.Reference
}
}
} else { // Triggered on a link without a fragment so we only require matching the file and ignore fragments
references.push(...this.findAllLinksToFile(targetDoc.uri, allLinksInWorkspace, sourceLink));
references.push(...this.findAllLinksToFile(targetDoc?.uri ?? sourceLink.href.path, allLinksInWorkspace, sourceLink));
}
return references;
@ -297,3 +289,19 @@ export class MdReferencesProvider extends Disposable implements vscode.Reference
: link.source.hrefRange;
}
}
export async function tryFindMdDocumentForLink(href: InternalHref, workspaceContents: MdWorkspaceContents): Promise<SkinnyTextDocument | undefined> {
const targetDoc = await workspaceContents.getMarkdownDocument(href.path);
if (targetDoc) {
return targetDoc;
}
// We don't think the file exists. If it doesn't already have an extension, try tacking on a `.md` and using that instead
if (uri.Utils.extname(href.path) === '') {
const dotMdResource = href.path.with({ path: href.path.path + '.md' });
return workspaceContents.getMarkdownDocument(dotMdResource);
}
return undefined;
}

View file

@ -6,12 +6,35 @@ import * as vscode from 'vscode';
import * as nls from 'vscode-nls';
import { Slugifier } from '../slugify';
import { Disposable } from '../util/dispose';
import { SkinnyTextDocument } from '../workspaceContents';
import { MdHeaderReference, MdReference, MdReferencesProvider } from './references';
import { resolveDocumentLink } from '../util/openDocumentLink';
import { MdWorkspaceContents, SkinnyTextDocument } from '../workspaceContents';
import { InternalHref } from './documentLinkProvider';
import { MdHeaderReference, MdLinkReference, MdReference, MdReferencesProvider, tryFindMdDocumentForLink } from './references';
const localize = nls.loadMessageBundle();
export interface MdReferencesResponse {
references: MdReference[];
triggerRef: MdReference;
}
interface MdFileRenameEdit {
readonly from: string;
readonly to: string;
}
/**
* Type with additional metadata about the edits for testing
*
* This is needed since `vscode.WorkspaceEdit` does not expose info on file renames.
*/
export interface MdWorkspaceEdit {
readonly edit: vscode.WorkspaceEdit;
readonly fileRenames?: ReadonlyArray<MdFileRenameEdit>;
}
export class MdRenameProvider extends Disposable implements vscode.RenameProvider {
private cachedRefs?: {
@ -22,8 +45,11 @@ export class MdRenameProvider extends Disposable implements vscode.RenameProvide
readonly references: MdReference[];
} | undefined;
private readonly renameNotSupportedText = localize('invalidRenameLocation', "Rename not supported at location");
public constructor(
private readonly referencesProvider: MdReferencesProvider,
private readonly workspaceContents: MdWorkspaceContents,
private readonly slugifier: Slugifier,
) {
super();
@ -36,7 +62,7 @@ export class MdRenameProvider extends Disposable implements vscode.RenameProvide
}
if (!allRefsInfo || !allRefsInfo.references.length) {
throw new Error(localize('invalidRenameLocation', "Rename not supported at location"));
throw new Error(this.renameNotSupportedText);
}
const triggerRef = allRefsInfo.triggerRef;
@ -56,8 +82,9 @@ export class MdRenameProvider extends Disposable implements vscode.RenameProvide
return { range: triggerRef.link.source.hrefRange, placeholder: document.getText(triggerRef.link.source.hrefRange) };
}
// See if we are renaming the fragment or the path
const { fragmentRange } = triggerRef.link.source;
if (fragmentRange) {
if (fragmentRange?.contains(position)) {
const declaration = this.findHeaderDeclaration(allRefsInfo.references);
if (declaration) {
return { range: fragmentRange, placeholder: declaration.headerText };
@ -65,16 +92,31 @@ export class MdRenameProvider extends Disposable implements vscode.RenameProvide
return { range: fragmentRange, placeholder: document.getText(fragmentRange) };
}
throw new Error(localize('renameNoFiles', "Renaming files is currently not supported"));
const range = this.getFilePathRange(triggerRef);
if (!range) {
throw new Error(this.renameNotSupportedText);
}
return { range, placeholder: document.getText(range) };
}
}
}
private getFilePathRange(ref: MdLinkReference): vscode.Range {
if (ref.link.source.fragmentRange) {
return ref.link.source.hrefRange.with(undefined, ref.link.source.fragmentRange.start.translate(0, -1));
}
return ref.link.source.hrefRange;
}
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> {
return (await this.provideRenameEditsImpl(document, position, newName, token))?.edit;
}
public async provideRenameEditsImpl(document: SkinnyTextDocument, position: vscode.Position, newName: string, token: vscode.CancellationToken): Promise<MdWorkspaceEdit | undefined> {
const allRefsInfo = await this.getAllReferences(document, position, token);
if (token.isCancellationRequested || !allRefsInfo || !allRefsInfo.references.length) {
return undefined;
@ -82,9 +124,44 @@ export class MdRenameProvider extends Disposable implements vscode.RenameProvide
const triggerRef = allRefsInfo.triggerRef;
const isRefRename = triggerRef.kind === 'link' && (
if (triggerRef.kind === 'link' && (
(triggerRef.link.kind === 'definition' && triggerRef.link.ref.range.contains(position)) || triggerRef.link.href.kind === 'reference'
);
)) {
return this.renameReferenceLinks(allRefsInfo, newName);
} else if (triggerRef.kind === 'link' && triggerRef.link.href.kind === 'external') {
return this.renameExternalLink(allRefsInfo, newName);
} else if (triggerRef.kind === 'header' || (triggerRef.kind === 'link' && triggerRef.link.source.fragmentRange?.contains(position) && (triggerRef.link.kind === 'definition' || triggerRef.link.kind === 'link' && triggerRef.link.href.kind === 'internal'))) {
return this.renameFragment(allRefsInfo, newName);
} else if (triggerRef.kind === 'link' && !triggerRef.link.source.fragmentRange?.contains(position) && triggerRef.link.kind === 'link' && triggerRef.link.href.kind === 'internal') {
return this.renameFilePath(triggerRef.link.href, allRefsInfo, newName);
}
return undefined;
}
private async renameFilePath(triggerHref: InternalHref, allRefsInfo: MdReferencesResponse, newName: string): Promise<MdWorkspaceEdit> {
const edit = new vscode.WorkspaceEdit();
const fileRenames: MdFileRenameEdit[] = [];
const targetDoc = await tryFindMdDocumentForLink(triggerHref, this.workspaceContents);
const targetUri = targetDoc?.uri ?? triggerHref.path;
const newFilePath = resolveDocumentLink(newName, triggerHref.path);
// First rename the file
fileRenames.push({ from: targetUri.toString(), to: newFilePath.toString() });
edit.renameFile(targetUri, newFilePath);
// Then update all refs to it
for (const ref of allRefsInfo.references) {
if (ref.kind === 'link') {
edit.replace(ref.link.source.resource, this.getFilePathRange(ref), encodeURI(newName));
}
}
return { edit, fileRenames };
}
private renameFragment(allRefsInfo: MdReferencesResponse, newName: string): MdWorkspaceEdit {
const slug = this.slugifier.fromHeading(newName).value;
const edit = new vscode.WorkspaceEdit();
@ -95,22 +172,38 @@ export class MdRenameProvider extends Disposable implements vscode.RenameProvide
break;
case 'link':
if (ref.link.kind === 'definition') {
// We may be renaming either the reference or the definition itself
if (isRefRename) {
edit.replace(ref.link.source.resource, ref.link.ref.range, newName);
continue;
}
}
edit.replace(ref.link.source.resource, ref.link.source.fragmentRange ?? ref.location.range, isRefRename && !ref.link.source.fragmentRange || ref.link.href.kind === 'external' ? newName : slug);
edit.replace(ref.link.source.resource, ref.link.source.fragmentRange ?? ref.location.range, !ref.link.source.fragmentRange || ref.link.href.kind === 'external' ? newName : slug);
break;
}
}
return edit;
return { edit };
}
private async getAllReferences(document: SkinnyTextDocument, position: vscode.Position, token: vscode.CancellationToken): Promise<{ references: MdReference[]; triggerRef: MdReference } | undefined> {
private renameExternalLink(allRefsInfo: MdReferencesResponse, newName: string): MdWorkspaceEdit {
const edit = new vscode.WorkspaceEdit();
for (const ref of allRefsInfo.references) {
if (ref.kind === 'link') {
edit.replace(ref.link.source.resource, ref.location.range, newName);
}
}
return { edit };
}
private renameReferenceLinks(allRefsInfo: MdReferencesResponse, newName: string): MdWorkspaceEdit {
const edit = new vscode.WorkspaceEdit();
for (const ref of allRefsInfo.references) {
if (ref.kind === 'link') {
if (ref.link.kind === 'definition') {
edit.replace(ref.link.source.resource, ref.link.ref.range, newName);
} else {
edit.replace(ref.link.source.resource, ref.link.source.fragmentRange ?? ref.location.range, newName);
}
}
}
return { edit };
}
private async getAllReferences(document: SkinnyTextDocument, position: vscode.Position, token: vscode.CancellationToken): Promise<MdReferencesResponse | undefined> {
const version = document.version;
if (this.cachedRefs

View file

@ -164,7 +164,7 @@ suite('markdown: find all references', () => {
);
});
test('Should find references from link definition', async () => {
test('Should find header references from link definition', async () => {
const uri = workspacePath('doc.md');
const doc = new InMemoryDocument(uri, joinLines(
`# A b C`,
@ -466,6 +466,28 @@ suite('markdown: find all references', () => {
}
});
test('Should support finding references to unknown file', async () => {
const uri1 = workspacePath('doc1.md');
const doc1 = new InMemoryDocument(uri1, joinLines(
`![img](/images/more/image.png)`,
``,
`[ref]: /images/more/image.png`,
));
const uri2 = workspacePath('sub', 'doc2.md');
const doc2 = new InMemoryDocument(uri2, joinLines(
`![img](/images/more/image.png)`,
));
const refs = await getReferences(doc1, new vscode.Position(0, 10), new InMemoryWorkspaceMarkdownDocuments([doc1, doc2]));
assertReferencesEqual(refs!,
{ uri: uri1, line: 0 },
{ uri: uri1, line: 2 },
{ uri: uri2, line: 0 },
);
});
suite('Reference links', () => {
test('Should find reference links within file from link', async () => {
const docUri = workspacePath('doc.md');

View file

@ -8,7 +8,7 @@ import 'mocha';
import * as vscode from 'vscode';
import { MdLinkProvider } from '../languageFeatures/documentLinkProvider';
import { MdReferencesProvider } from '../languageFeatures/references';
import { MdRenameProvider } from '../languageFeatures/rename';
import { MdRenameProvider, MdWorkspaceEdit } from '../languageFeatures/rename';
import { githubSlugifier } from '../slugify';
import { InMemoryDocument } from '../util/inMemoryDocument';
import { MdWorkspaceContents } from '../workspaceContents';
@ -24,37 +24,62 @@ function prepareRename(doc: InMemoryDocument, pos: vscode.Position, workspaceCon
const engine = createNewMarkdownEngine();
const linkProvider = new MdLinkProvider(engine);
const referencesProvider = new MdReferencesProvider(linkProvider, workspaceContents, engine, githubSlugifier);
const renameProvider = new MdRenameProvider(referencesProvider, githubSlugifier);
const renameProvider = new MdRenameProvider(referencesProvider, workspaceContents, githubSlugifier);
return renameProvider.prepareRename(doc, pos, noopToken);
}
/**
* Get all the edits for the rename.
*/
function getRenameEdits(doc: InMemoryDocument, pos: vscode.Position, newName: string, workspaceContents: MdWorkspaceContents) {
function getRenameEdits(doc: InMemoryDocument, pos: vscode.Position, newName: string, workspaceContents: MdWorkspaceContents): Promise<MdWorkspaceEdit | undefined> {
const engine = createNewMarkdownEngine();
const linkProvider = new MdLinkProvider(engine);
const referencesProvider = new MdReferencesProvider(linkProvider, workspaceContents, engine, githubSlugifier);
const renameProvider = new MdRenameProvider(referencesProvider, githubSlugifier);
return renameProvider.provideRenameEdits(doc, pos, newName, noopToken);
const renameProvider = new MdRenameProvider(referencesProvider, workspaceContents, githubSlugifier);
return renameProvider.provideRenameEditsImpl(doc, pos, newName, noopToken);
}
function assertEditsEqual(actualEdit: vscode.WorkspaceEdit, ...expectedEdits: { uri: vscode.Uri; edits: vscode.TextEdit[] }[]) {
const actualEntries = actualEdit.entries();
assert.strictEqual(actualEntries.length, expectedEdits.length, `Reference counts should match`);
interface ExpectedTextEdit {
readonly uri: vscode.Uri;
readonly edits: readonly vscode.TextEdit[];
}
for (let i = 0; i < actualEntries.length; ++i) {
const actual = actualEntries[i];
const expected = expectedEdits[i];
assert.strictEqual(actual[0].toString(), expected.uri.toString(), `Ref '${i}' has expected document`);
interface ExpectedFileRename {
readonly originalUri: vscode.Uri;
readonly newUri: vscode.Uri;
}
const actualEditForDoc = actual[1];
const expectedEditsForDoc = expected.edits;
assert.strictEqual(actualEditForDoc.length, expectedEditsForDoc.length, `Edit counts for '${actual[0]}' should match`);
function assertEditsEqual(actualEdit: MdWorkspaceEdit, ...expectedEdits: ReadonlyArray<ExpectedTextEdit | ExpectedFileRename>) {
// Check file renames
const expectedFileRenames = expectedEdits.filter(expected => 'originalUri' in expected) as ExpectedFileRename[];
const actualFileRenames = actualEdit.fileRenames ?? [];
assert.strictEqual(actualFileRenames.length, expectedFileRenames.length, `File rename count should match`);
for (let i = 0; i < actualFileRenames.length; ++i) {
const expected = expectedFileRenames[i];
const actual = actualFileRenames[i];
assert.strictEqual(actual.from.toString(), expected.originalUri.toString(), `File rename '${i}' should have expected 'from' resource`);
assert.strictEqual(actual.to.toString(), expected.newUri.toString(), `File rename '${i}' should have expected 'to' resource`);
}
for (let g = 0; g < actualEditForDoc.length; ++g) {
assertRangeEqual(actualEditForDoc[g].range, expectedEditsForDoc[g].range, `Edit '${g}' of '${actual[0]}' has expected expected range. Expected range: ${JSON.stringify(actualEditForDoc[g].range)}. Actual range: ${JSON.stringify(expectedEditsForDoc[g].range)}`);
assert.strictEqual(actualEditForDoc[g].newText, expectedEditsForDoc[g].newText, `Edit '${g}' of '${actual[0]}' has expected edits`);
// Check text edits
const actualTextEdits = actualEdit.edit.entries();
const expectedTextEdits = expectedEdits.filter(expected => 'edits' in expected) as ExpectedTextEdit[];
assert.strictEqual(actualTextEdits.length, expectedTextEdits.length, `Reference counts should match`);
for (let i = 0; i < actualTextEdits.length; ++i) {
const expected = expectedTextEdits[i];
const actual = actualTextEdits[i];
if ('edits' in expected) {
assert.strictEqual(actual[0].toString(), expected.uri.toString(), `Ref '${i}' has expected document`);
const actualEditForDoc = actual[1];
const expectedEditsForDoc = expected.edits;
assert.strictEqual(actualEditForDoc.length, expectedEditsForDoc.length, `Edit counts for '${actual[0]}' should match`);
for (let g = 0; g < actualEditForDoc.length; ++g) {
assertRangeEqual(actualEditForDoc[g].range, expectedEditsForDoc[g].range, `Edit '${g}' of '${actual[0]}' has expected expected range. Expected range: ${JSON.stringify(actualEditForDoc[g].range)}. Actual range: ${JSON.stringify(expectedEditsForDoc[g].range)}`);
assert.strictEqual(actualEditForDoc[g].newText, expectedEditsForDoc[g].newText, `Edit '${g}' of '${actual[0]}' has expected edits`);
}
}
}
}
@ -325,24 +350,117 @@ suite('markdown: rename', () => {
await assert.rejects(prepareRename(doc, new vscode.Position(1, 2), new InMemoryWorkspaceMarkdownDocuments([doc])));
});
test('Rename should not be supported on bare file link', async () => {
const uri = workspacePath('doc.md');
const doc = new InMemoryDocument(uri, joinLines(
`[text](./doc.md)`,
`[other](./doc.md)`,
));
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 () => {
test('Path rename should use file path as range', async () => {
const uri = workspacePath('doc.md');
const doc = new InMemoryDocument(uri, joinLines(
`[text](./doc.md)`,
`[ref]: ./doc.md`,
));
await assert.rejects(prepareRename(doc, new vscode.Position(1, 10), new InMemoryWorkspaceMarkdownDocuments([doc])));
const info = await prepareRename(doc, new vscode.Position(0, 10), new InMemoryWorkspaceMarkdownDocuments([doc]));
assert.strictEqual(info!.placeholder, './doc.md');
assertRangeEqual(info!.range, new vscode.Range(0, 7, 0, 15));
});
test('Path rename\'s range should excludes fragment', async () => {
const uri = workspacePath('doc.md');
const doc = new InMemoryDocument(uri, joinLines(
`[text](./doc.md#some-header)`,
`[ref]: ./doc.md#some-header`,
));
const info = await prepareRename(doc, new vscode.Position(0, 10), new InMemoryWorkspaceMarkdownDocuments([doc]));
assert.strictEqual(info!.placeholder, './doc.md');
assertRangeEqual(info!.range, new vscode.Range(0, 7, 0, 15));
});
test('Path rename should update file and all refs', async () => {
const uri = workspacePath('doc.md');
const doc = new InMemoryDocument(uri, joinLines(
`[text](./doc.md)`,
`[ref]: ./doc.md`,
));
const edit = await getRenameEdits(doc, new vscode.Position(0, 10), './sub/newDoc.md', new InMemoryWorkspaceMarkdownDocuments([doc]));
assertEditsEqual(edit!, {
originalUri: uri,
newUri: workspacePath('sub', 'newDoc.md'),
}, {
uri: uri, edits: [
new vscode.TextEdit(new vscode.Range(0, 7, 0, 15), './sub/newDoc.md'),
new vscode.TextEdit(new vscode.Range(1, 7, 1, 15), './sub/newDoc.md'),
]
});
});
test('Path rename using absolute file path should anchor to workspace root', async () => {
const uri = workspacePath('sub', 'doc.md');
const doc = new InMemoryDocument(uri, joinLines(
`[text](/sub/doc.md)`,
`[ref]: /sub/doc.md`,
));
const edit = await getRenameEdits(doc, new vscode.Position(0, 10), '/newSub/newDoc.md', new InMemoryWorkspaceMarkdownDocuments([doc]));
assertEditsEqual(edit!, {
originalUri: uri,
newUri: workspacePath('newSub', 'newDoc.md'),
}, {
uri: uri, edits: [
new vscode.TextEdit(new vscode.Range(0, 7, 0, 18), '/newSub/newDoc.md'),
new vscode.TextEdit(new vscode.Range(1, 7, 1, 18), '/newSub/newDoc.md'),
]
});
});
test('Path rename should encode paths', async () => {
const uri = workspacePath('sub', 'doc.md');
const doc = new InMemoryDocument(uri, joinLines(
`[text](/sub/doc.md)`,
`[ref]: /sub/doc.md`,
));
const edit = await getRenameEdits(doc, new vscode.Position(0, 10), '/NEW sub/new DOC.md', new InMemoryWorkspaceMarkdownDocuments([doc]));
assertEditsEqual(edit!, {
originalUri: uri,
newUri: workspacePath('NEW sub', 'new DOC.md'),
}, {
uri: uri, edits: [
new vscode.TextEdit(new vscode.Range(0, 7, 0, 18), '/NEW%20sub/new%20DOC.md'),
new vscode.TextEdit(new vscode.Range(1, 7, 1, 18), '/NEW%20sub/new%20DOC.md'),
]
});
});
test('Path rename should work with unknown files', async () => {
const uri1 = workspacePath('doc1.md');
const doc1 = new InMemoryDocument(uri1, joinLines(
`![img](/images/more/image.png)`,
``,
`[ref]: /images/more/image.png`,
));
const uri2 = workspacePath('sub', 'doc2.md');
const doc2 = new InMemoryDocument(uri2, joinLines(
`![img](/images/more/image.png)`,
));
const edit = await getRenameEdits(doc1, new vscode.Position(0, 10), '/img/test/new.png', new InMemoryWorkspaceMarkdownDocuments([
doc1,
doc2
]));
assertEditsEqual(edit!, {
originalUri: workspacePath('images', 'more', 'image.png'),
newUri: workspacePath('img', 'test', 'new.png'),
}, {
uri: uri1, edits: [
new vscode.TextEdit(new vscode.Range(0, 7, 0, 29), '/img/test/new.png'),
new vscode.TextEdit(new vscode.Range(2, 7, 2, 29), '/img/test/new.png'),
]
}, {
uri: uri2, edits: [
new vscode.TextEdit(new vscode.Range(0, 7, 0, 29), '/img/test/new.png'),
]
});
});
test('Rename on link should use header text as placeholder', async () => {
@ -357,7 +475,7 @@ suite('markdown: rename', () => {
assertRangeEqual(info!.range, new vscode.Range(1, 8, 1, 13));
});
test('Rename on http uri should work ', async () => {
test('Rename on http uri should work', async () => {
const uri1 = workspacePath('doc.md');
const uri2 = workspacePath('doc2.md');
const doc = new InMemoryDocument(uri1, joinLines(