Add basic file references provider for markdown

Fixes #146267
This commit is contained in:
Matt Bierner 2022-04-05 11:53:56 -07:00
parent 111b58221c
commit 0496c2b3a7
No known key found for this signature in database
GPG key ID: 099C331567E11888
8 changed files with 245 additions and 37 deletions

View file

@ -29,6 +29,7 @@
"onCommand:markdown.showPreviewSecuritySelector",
"onCommand:markdown.api.render",
"onCommand:markdown.api.reloadPlugins",
"onCommand:markdown.findAllFileReferences",
"onWebviewPanel:markdown.preview",
"onCustomEditor:vscode.markdown.preview.editor"
],
@ -169,6 +170,11 @@
"command": "markdown.preview.toggleLock",
"title": "%markdown.preview.toggleLock.title%",
"category": "Markdown"
},
{
"command": "markdown.findAllFileReferences",
"title": "%markdown.findAllFileReferences%",
"category": "Markdown"
}
],
"menus": {
@ -205,6 +211,11 @@
"command": "markdown.showPreview",
"when": "resourceLangId == markdown && !hasCustomMarkdownPreview",
"group": "navigation"
},
{
"command": "markdown.findAllFileReferences",
"when": "resourceLangId == markdown",
"group": "4_search"
}
],
"editor/title/context": [
@ -212,6 +223,10 @@
"command": "markdown.showPreview",
"when": "resourceLangId == markdown && !hasCustomMarkdownPreview",
"group": "1_open"
},
{
"command": "markdown.findAllFileReferences",
"when": "resourceLangId == markdown"
}
],
"commandPalette": [
@ -254,6 +269,10 @@
{
"command": "markdown.preview.refresh",
"when": "markdownPreviewFocus"
},
{
"command": "markdown.findAllFileReferences",
"when": "editorLangId == markdown"
}
]
},

View file

@ -20,6 +20,7 @@
"markdown.trace.desc": "Enable debug logging for the Markdown extension.",
"markdown.preview.refresh.title": "Refresh Preview",
"markdown.preview.toggleLock.title": "Toggle Preview Locking",
"markdown.findAllFileReferences": "Find File References",
"configuration.markdown.preview.openMarkdownLinks.description": "Controls how links to other Markdown files in the Markdown preview should be opened.",
"configuration.markdown.preview.openMarkdownLinks.inEditor": "Try to open links in the editor.",
"configuration.markdown.preview.openMarkdownLinks.inPreview": "Try to open links in the Markdown preview.",

View file

@ -21,9 +21,11 @@ export class CommandManager {
this.commands.clear();
}
public register<T extends Command>(command: T): T {
public register<T extends Command>(command: T): vscode.Disposable {
this.registerCommand(command.id, command.execute, command);
return command;
return new vscode.Disposable(() => {
this.commands.delete(command.id);
});
}
private registerCommand(id: string, impl: (...args: any[]) => void, thisArg?: any) {
@ -33,4 +35,4 @@ export class CommandManager {
this.commands.set(id, vscode.commands.registerCommand(id, impl, thisArg));
}
}
}

View file

@ -9,6 +9,7 @@ import * as commands from './commands/index';
import { MdLinkProvider } from './languageFeatures/documentLinkProvider';
import { MdDocumentSymbolProvider } from './languageFeatures/documentSymbolProvider';
import { registerDropIntoEditor } from './languageFeatures/dropIntoEditor';
import { registerFindFileReferences } from './languageFeatures/fileReferences';
import { MdFoldingProvider } from './languageFeatures/foldingProvider';
import { MdPathCompletionProvider } from './languageFeatures/pathCompletions';
import { MdReferencesProvider } from './languageFeatures/references';
@ -36,14 +37,15 @@ export function activate(context: vscode.ExtensionContext) {
const cspArbiter = new ExtensionContentSecurityPolicyArbiter(context.globalState, context.workspaceState);
const engine = new MarkdownEngine(contributions, githubSlugifier);
const logger = new Logger();
const commandManager = new CommandManager();
const contentProvider = new MarkdownContentProvider(engine, context, cspArbiter, contributions, logger);
const symbolProvider = new MdDocumentSymbolProvider(engine);
const previewManager = new MarkdownPreviewManager(contentProvider, logger, contributions, engine);
context.subscriptions.push(previewManager);
context.subscriptions.push(registerMarkdownLanguageFeatures(symbolProvider, engine));
context.subscriptions.push(registerMarkdownCommands(previewManager, telemetryReporter, cspArbiter, engine));
context.subscriptions.push(registerMarkdownLanguageFeatures(commandManager, symbolProvider, engine));
context.subscriptions.push(registerMarkdownCommands(commandManager, previewManager, telemetryReporter, cspArbiter, engine));
context.subscriptions.push(vscode.workspace.onDidChangeConfiguration(() => {
logger.updateConfiguration();
@ -52,6 +54,7 @@ export function activate(context: vscode.ExtensionContext) {
}
function registerMarkdownLanguageFeatures(
commandManager: CommandManager,
symbolProvider: MdDocumentSymbolProvider,
engine: MarkdownEngine
): vscode.Disposable {
@ -71,10 +74,12 @@ function registerMarkdownLanguageFeatures(
vscode.languages.registerRenameProvider(selector, new MdRenameProvider(referencesProvider, githubSlugifier)),
MdPathCompletionProvider.register(selector, engine, linkProvider),
registerDropIntoEditor(selector),
registerFindFileReferences(commandManager, referencesProvider),
);
}
function registerMarkdownCommands(
commandManager: CommandManager,
previewManager: MarkdownPreviewManager,
telemetryReporter: TelemetryReporter,
cspArbiter: ContentSecurityPolicyArbiter,
@ -82,7 +87,6 @@ function registerMarkdownCommands(
): vscode.Disposable {
const previewSecuritySelector = new PreviewSecuritySelector(cspArbiter, previewManager);
const commandManager = new CommandManager();
commandManager.register(new commands.ShowPreviewCommand(previewManager, telemetryReporter));
commandManager.register(new commands.ShowPreviewToSideCommand(previewManager, telemetryReporter));
commandManager.register(new commands.ShowLockedPreviewToSideCommand(previewManager, telemetryReporter));

View file

@ -0,0 +1,54 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import * as nls from 'vscode-nls';
import { Command, CommandManager } from '../commandManager';
import { MdReferencesProvider } from './references';
const localize = nls.loadMessageBundle();
export class FindFileReferencesCommand implements Command {
public readonly id = 'markdown.findAllFileReferences';
constructor(
private readonly referencesProvider: MdReferencesProvider,
) { }
public async execute(resource?: vscode.Uri) {
if (!resource) {
resource = vscode.window.activeTextEditor?.document.uri;
}
if (!resource) {
vscode.window.showErrorMessage(localize('error.noResource', "Find file references failed. No resource provided."));
return;
}
await vscode.window.withProgress({
location: vscode.ProgressLocation.Window,
title: localize('progress.title', "Finding file references")
}, async (_progress, token) => {
const references = await this.referencesProvider.getAllReferencesToFile(resource!, token);
const locations = references.map(ref => ref.location);
const config = vscode.workspace.getConfiguration('references');
const existingSetting = config.inspect<string>('preferredLocation');
await config.update('preferredLocation', 'view');
try {
await vscode.commands.executeCommand('editor.action.showReferences', resource, new vscode.Position(0, 0), locations);
} finally {
await config.update('preferredLocation', existingSetting?.workspaceFolderValue ?? existingSetting?.workspaceValue);
}
});
}
}
export function registerFindFileReferences(commandManager: CommandManager, referencesProvider: MdReferencesProvider): vscode.Disposable {
return commandManager.register(new FindFileReferencesCommand(referencesProvider));
}

View file

@ -87,14 +87,14 @@ export class MdReferencesProvider extends Disposable implements vscode.Reference
}
async provideReferences(document: SkinnyTextDocument, position: vscode.Position, context: vscode.ReferenceContext, token: vscode.CancellationToken): Promise<vscode.Location[] | undefined> {
const allRefs = await this.getAllReferences(document, position, token);
const allRefs = await this.getAllReferencesAtPosition(document, position, token);
return allRefs
.filter(ref => context.includeDeclaration || !ref.isDefinition)
.map(ref => ref.location);
}
public async getAllReferences(document: SkinnyTextDocument, position: vscode.Position, token: vscode.CancellationToken): Promise<MdReference[]> {
public async getAllReferencesAtPosition(document: SkinnyTextDocument, position: vscode.Position, token: vscode.CancellationToken): Promise<MdReference[]> {
const toc = await TableOfContents.create(this.engine, document);
if (token.isCancellationRequested) {
return [];
@ -124,7 +124,7 @@ export class MdReferencesProvider extends Disposable implements vscode.Reference
for (const link of links) {
if (link.href.kind === 'internal'
&& this.looksLikeLinkToDoc(link.href, document)
&& this.looksLikeLinkToDoc(link.href, document.uri)
&& this.slugifier.fromHeading(link.href.fragment).value === header.slug.value
) {
references.push({
@ -203,32 +203,14 @@ export class MdReferencesProvider extends Disposable implements vscode.Reference
}
}
for (const link of allLinksInWorkspace) {
if (link.href.kind !== 'internal') {
continue;
}
if (sourceLink.href.fragment) {
for (const link of allLinksInWorkspace) {
if (link.href.kind !== 'internal' || !this.looksLikeLinkToDoc(link.href, targetDoc.uri)) {
continue;
}
if (!this.looksLikeLinkToDoc(link.href, targetDoc)) {
continue;
}
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))) {
references.push({
kind: 'link',
isTriggerLocation,
isDefinition: false,
link,
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.source.text.startsWith('#') || link.source.resource.fsPath !== targetDoc.uri.fsPath) {
const isTriggerLocation = sourceLink.source.resource.fsPath === link.source.resource.fsPath && sourceLink.source.hrefRange.isEqual(link.source.hrefRange);
references.push({
kind: 'link',
isTriggerLocation,
@ -239,14 +221,42 @@ 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));
}
return references;
}
private looksLikeLinkToDoc(href: InternalHref, targetDoc: SkinnyTextDocument) {
return href.path.fsPath === targetDoc.uri.fsPath
|| uri.Utils.extname(href.path) === '' && href.path.with({ path: href.path.path + '.md' }).fsPath === targetDoc.uri.fsPath;
private looksLikeLinkToDoc(href: InternalHref, targetDoc: vscode.Uri) {
return href.path.fsPath === targetDoc.fsPath
|| uri.Utils.extname(href.path) === '' && href.path.with({ path: href.path.path + '.md' }).fsPath === targetDoc.fsPath;
}
public async getAllReferencesToFile(resource: vscode.Uri, _token: vscode.CancellationToken): Promise<MdReference[]> {
const allLinksInWorkspace = (await this._linkCache.getAll()).flat();
return Array.from(this.findAllLinksToFile(resource, allLinksInWorkspace, undefined));
}
private *findAllLinksToFile(resource: vscode.Uri, allLinksInWorkspace: readonly MdLink[], sourceLink: MdLink | undefined): Iterable<MdReference> {
for (const link of allLinksInWorkspace) {
if (link.href.kind !== 'internal' || !this.looksLikeLinkToDoc(link.href, resource)) {
continue;
}
// Exclude cases where the file is implicitly referencing itself
if (!link.source.text.startsWith('#') || link.source.resource.fsPath !== resource.fsPath) {
const isTriggerLocation = !!sourceLink && sourceLink.source.resource.fsPath === link.source.resource.fsPath && sourceLink.source.hrefRange.isEqual(link.source.hrefRange);
yield {
kind: 'link',
isTriggerLocation,
isDefinition: false,
link,
location: new vscode.Location(link.source.resource, link.source.hrefRange),
fragmentLocation: getFragmentLocation(link),
};
}
}
}
private *getReferencesToLinkReference(allLinks: Iterable<MdLink>, refToFind: string, from: { resource: vscode.Uri; range: vscode.Range }): Iterable<MdReference> {

View file

@ -115,7 +115,7 @@ export class MdRenameProvider extends Disposable implements vscode.RenameProvide
return this.cachedRefs;
}
const references = await this.referencesProvider.getAllReferences(document, position, token);
const references = await this.referencesProvider.getAllReferencesAtPosition(document, position, token);
const triggerRef = references.find(ref => ref.isTriggerLocation);
if (!triggerRef) {
return undefined;

View file

@ -0,0 +1,118 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as assert from 'assert';
import 'mocha';
import * as vscode from 'vscode';
import { MdLinkProvider } from '../languageFeatures/documentLinkProvider';
import { MdReference, MdReferencesProvider } from '../languageFeatures/references';
import { githubSlugifier } from '../slugify';
import { InMemoryDocument } from '../util/inMemoryDocument';
import { MdWorkspaceContents } from '../workspaceContents';
import { createNewMarkdownEngine } from './engine';
import { InMemoryWorkspaceMarkdownDocuments } from './inMemoryWorkspace';
import { joinLines, noopToken, workspacePath } from './util';
function getFileReferences(resource: vscode.Uri, workspaceContents: MdWorkspaceContents) {
const engine = createNewMarkdownEngine();
const linkProvider = new MdLinkProvider(engine);
const provider = new MdReferencesProvider(linkProvider, workspaceContents, engine, githubSlugifier);
return provider.getAllReferencesToFile(resource, noopToken);
}
function assertReferencesEqual(actualRefs: readonly MdReference[], ...expectedRefs: { uri: vscode.Uri; line: number }[]) {
assert.strictEqual(actualRefs.length, expectedRefs.length, `Reference counts should match`);
for (let i = 0; i < actualRefs.length; ++i) {
const actual = actualRefs[i].location;
const expected = expectedRefs[i];
assert.strictEqual(actual.uri.toString(), expected.uri.toString(), `Ref '${i}' has expected document`);
assert.strictEqual(actual.range.start.line, expected.line, `Ref '${i}' has expected start line`);
assert.strictEqual(actual.range.end.line, expected.line, `Ref '${i}' has expected end line`);
}
}
suite('markdown: find file references', () => {
test('Should find basic references', async () => {
const docUri = workspacePath('doc.md');
const otherUri = workspacePath('other.md');
const refs = await getFileReferences(otherUri, new InMemoryWorkspaceMarkdownDocuments([
new InMemoryDocument(docUri, joinLines(
`# header`,
`[link 1](./other.md)`,
`[link 2](./other.md)`,
)),
new InMemoryDocument(otherUri, joinLines(
`# header`,
`pre`,
`[link 3](./other.md)`,
`post`,
)),
]));
assertReferencesEqual(refs!,
{ uri: docUri, line: 1 },
{ uri: docUri, line: 2 },
{ uri: otherUri, line: 2 },
);
});
test('Should find references with and without file extensions', async () => {
const docUri = workspacePath('doc.md');
const otherUri = workspacePath('other.md');
const refs = await getFileReferences(otherUri, new InMemoryWorkspaceMarkdownDocuments([
new InMemoryDocument(docUri, joinLines(
`# header`,
`[link 1](./other.md)`,
`[link 2](./other)`,
)),
new InMemoryDocument(otherUri, joinLines(
`# header`,
`pre`,
`[link 3](./other.md)`,
`[link 4](./other)`,
`post`,
)),
]));
assertReferencesEqual(refs!,
{ uri: docUri, line: 1 },
{ uri: docUri, line: 2 },
{ uri: otherUri, line: 2 },
{ uri: otherUri, line: 3 },
);
});
test('Should find references with headers on links', async () => {
const docUri = workspacePath('doc.md');
const otherUri = workspacePath('other.md');
const refs = await getFileReferences(otherUri, new InMemoryWorkspaceMarkdownDocuments([
new InMemoryDocument(docUri, joinLines(
`# header`,
`[link 1](./other.md#sub-bla)`,
`[link 2](./other#sub-bla)`,
)),
new InMemoryDocument(otherUri, joinLines(
`# header`,
`pre`,
`[link 3](./other.md#sub-bla)`,
`[link 4](./other#sub-bla)`,
`post`,
)),
]));
assertReferencesEqual(refs!,
{ uri: docUri, line: 1 },
{ uri: docUri, line: 2 },
{ uri: otherUri, line: 2 },
{ uri: otherUri, line: 3 },
);
});
});