From 0496c2b3a7ca86a0d15d46aed03155bd8d6ee511 Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Tue, 5 Apr 2022 11:53:56 -0700 Subject: [PATCH] Add basic file references provider for markdown Fixes #146267 --- .../markdown-language-features/package.json | 19 +++ .../package.nls.json | 1 + .../src/commandManager.ts | 8 +- .../src/extension.ts | 10 +- .../src/languageFeatures/fileReferences.ts | 54 ++++++++ .../src/languageFeatures/references.ts | 70 ++++++----- .../src/languageFeatures/rename.ts | 2 +- .../src/test/fileReferences.test.ts | 118 ++++++++++++++++++ 8 files changed, 245 insertions(+), 37 deletions(-) create mode 100644 extensions/markdown-language-features/src/languageFeatures/fileReferences.ts create mode 100644 extensions/markdown-language-features/src/test/fileReferences.test.ts diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index ab0da0caffb..33d3be13b78 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -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" } ] }, diff --git a/extensions/markdown-language-features/package.nls.json b/extensions/markdown-language-features/package.nls.json index ba4d239f0e1..78927ee2a78 100644 --- a/extensions/markdown-language-features/package.nls.json +++ b/extensions/markdown-language-features/package.nls.json @@ -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.", diff --git a/extensions/markdown-language-features/src/commandManager.ts b/extensions/markdown-language-features/src/commandManager.ts index 359d5b9ca96..86609dbe2b2 100644 --- a/extensions/markdown-language-features/src/commandManager.ts +++ b/extensions/markdown-language-features/src/commandManager.ts @@ -21,9 +21,11 @@ export class CommandManager { this.commands.clear(); } - public register(command: T): T { + public register(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)); } -} \ No newline at end of file +} diff --git a/extensions/markdown-language-features/src/extension.ts b/extensions/markdown-language-features/src/extension.ts index abc4e70d6a4..e0c0477cce4 100644 --- a/extensions/markdown-language-features/src/extension.ts +++ b/extensions/markdown-language-features/src/extension.ts @@ -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)); diff --git a/extensions/markdown-language-features/src/languageFeatures/fileReferences.ts b/extensions/markdown-language-features/src/languageFeatures/fileReferences.ts new file mode 100644 index 00000000000..b9f1321cf06 --- /dev/null +++ b/extensions/markdown-language-features/src/languageFeatures/fileReferences.ts @@ -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('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)); +} diff --git a/extensions/markdown-language-features/src/languageFeatures/references.ts b/extensions/markdown-language-features/src/languageFeatures/references.ts index 590b66b38c0..74f37fee6bc 100644 --- a/extensions/markdown-language-features/src/languageFeatures/references.ts +++ b/extensions/markdown-language-features/src/languageFeatures/references.ts @@ -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 { - 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 { + public async getAllReferencesAtPosition(document: SkinnyTextDocument, position: vscode.Position, token: vscode.CancellationToken): Promise { 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 { + 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 { + 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, refToFind: string, from: { resource: vscode.Uri; range: vscode.Range }): Iterable { diff --git a/extensions/markdown-language-features/src/languageFeatures/rename.ts b/extensions/markdown-language-features/src/languageFeatures/rename.ts index 8889ea56c9c..5e613db365f 100644 --- a/extensions/markdown-language-features/src/languageFeatures/rename.ts +++ b/extensions/markdown-language-features/src/languageFeatures/rename.ts @@ -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; diff --git a/extensions/markdown-language-features/src/test/fileReferences.test.ts b/extensions/markdown-language-features/src/test/fileReferences.test.ts new file mode 100644 index 00000000000..b949f7e51bf --- /dev/null +++ b/extensions/markdown-language-features/src/test/fileReferences.test.ts @@ -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 }, + ); + }); +});