diff --git a/extensions/typescript-language-features/src/languageFeatures/completions.ts b/extensions/typescript-language-features/src/languageFeatures/completions.ts index 7412e7b49b5..372d621d977 100644 --- a/extensions/typescript-language-features/src/languageFeatures/completions.ts +++ b/extensions/typescript-language-features/src/languageFeatures/completions.ts @@ -194,9 +194,9 @@ class MyCompletionItem extends vscode.CompletionItem { const detail = response.body[0]; if (!this.detail && detail.displayParts.length) { - this.detail = Previewer.plain(detail.displayParts); + this.detail = Previewer.plainWithLinks(detail.displayParts, client); } - this.documentation = this.getDocumentation(detail, this); + this.documentation = this.getDocumentation(client, detail, this); const codeAction = this.getCodeActions(detail, filepath); const commands: vscode.Command[] = [{ @@ -237,16 +237,17 @@ class MyCompletionItem extends vscode.CompletionItem { } private getDocumentation( + client: ITypeScriptServiceClient, detail: Proto.CompletionEntryDetails, item: MyCompletionItem ): vscode.MarkdownString | undefined { const documentation = new vscode.MarkdownString(); if (detail.source) { - const importPath = `'${Previewer.plain(detail.source)}'`; + const importPath = `'${Previewer.plainWithLinks(detail.source, client)}'`; const autoImportLabel = localize('autoImportLabel', 'Auto import from {0}', importPath); item.detail = `${autoImportLabel}\n${item.detail}`; } - Previewer.addMarkdownDocumentation(documentation, detail.documentation, detail.tags); + Previewer.addMarkdownDocumentation(documentation, detail.documentation, detail.tags, client); return documentation.value.length ? documentation : undefined; } diff --git a/extensions/typescript-language-features/src/languageFeatures/fileConfigurationManager.ts b/extensions/typescript-language-features/src/languageFeatures/fileConfigurationManager.ts index f0c41541b76..47ad333c61e 100644 --- a/extensions/typescript-language-features/src/languageFeatures/fileConfigurationManager.ts +++ b/extensions/typescript-language-features/src/languageFeatures/fileConfigurationManager.ts @@ -173,7 +173,7 @@ export default class FileConfigurationManager extends Disposable { isTypeScriptDocument(document) ? 'typescript.preferences' : 'javascript.preferences', document.uri); - const preferences: Proto.UserPreferences = { + const preferences: Proto.UserPreferences & { displayPartsForJSDoc: true } = { quotePreference: this.getQuoteStylePreference(preferencesConfig), importModuleSpecifierPreference: getImportModuleSpecifierPreference(preferencesConfig), importModuleSpecifierEnding: getImportModuleSpecifierEndingPreference(preferencesConfig), @@ -183,6 +183,7 @@ export default class FileConfigurationManager extends Disposable { includeAutomaticOptionalChainCompletions: config.get('suggest.includeAutomaticOptionalChainCompletions', true), provideRefactorNotApplicableReason: true, generateReturnInDocTemplate: config.get('suggest.jsdoc.generateReturns', true), + displayPartsForJSDoc: true, }; return preferences; diff --git a/extensions/typescript-language-features/src/languageFeatures/hover.ts b/extensions/typescript-language-features/src/languageFeatures/hover.ts index 236ef694967..36f39971ecd 100644 --- a/extensions/typescript-language-features/src/languageFeatures/hover.ts +++ b/extensions/typescript-language-features/src/languageFeatures/hover.ts @@ -11,12 +11,14 @@ import { conditionalRegistration, requireSomeCapability } from '../utils/depende import { DocumentSelector } from '../utils/documentSelector'; import { markdownDocumentation } from '../utils/previewer'; import * as typeConverters from '../utils/typeConverters'; +import FileConfigurationManager from './fileConfigurationManager'; class TypeScriptHoverProvider implements vscode.HoverProvider { public constructor( - private readonly client: ITypeScriptServiceClient + private readonly client: ITypeScriptServiceClient, + private readonly fileConfigurationManager: FileConfigurationManager, ) { } public async provideHover( @@ -29,8 +31,13 @@ class TypeScriptHoverProvider implements vscode.HoverProvider { return undefined; } - const args = typeConverters.Position.toFileLocationRequestArgs(filepath, position); - const response = await this.client.interruptGetErr(() => this.client.execute('quickinfo', args, token)); + const response = await this.client.interruptGetErr(async () => { + await this.fileConfigurationManager.ensureConfigurationForDocument(document, token); + + const args = typeConverters.Position.toFileLocationRequestArgs(filepath, position); + return this.client.execute('quickinfo', args, token); + }); + if (response.type !== 'response' || !response.body) { return undefined; } @@ -62,19 +69,20 @@ class TypeScriptHoverProvider implements vscode.HoverProvider { parts.push({ language: 'typescript', value: displayParts.join(' ') }); } - parts.push(markdownDocumentation(data.documentation, data.tags)); + parts.push(markdownDocumentation(data.documentation, data.tags, this.client)); return parts; } } export function register( selector: DocumentSelector, - client: ITypeScriptServiceClient + client: ITypeScriptServiceClient, + fileConfigurationManager: FileConfigurationManager, ): vscode.Disposable { return conditionalRegistration([ requireSomeCapability(client, ClientCapability.EnhancedSyntax, ClientCapability.Semantic), ], () => { return vscode.languages.registerHoverProvider(selector.syntax, - new TypeScriptHoverProvider(client)); + new TypeScriptHoverProvider(client, fileConfigurationManager)); }); } diff --git a/extensions/typescript-language-features/src/languageFeatures/signatureHelp.ts b/extensions/typescript-language-features/src/languageFeatures/signatureHelp.ts index 93901b9bb25..177eda86ceb 100644 --- a/extensions/typescript-language-features/src/languageFeatures/signatureHelp.ts +++ b/extensions/typescript-language-features/src/languageFeatures/signatureHelp.ts @@ -72,19 +72,19 @@ class TypeScriptSignatureHelpProvider implements vscode.SignatureHelpProvider { private convertSignature(item: Proto.SignatureHelpItem) { const signature = new vscode.SignatureInformation( - Previewer.plain(item.prefixDisplayParts), - Previewer.markdownDocumentation(item.documentation, item.tags.filter(x => x.name !== 'param'))); + Previewer.plainWithLinks(item.prefixDisplayParts, this.client), + Previewer.markdownDocumentation(item.documentation, item.tags.filter(x => x.name !== 'param'), this.client)); let textIndex = signature.label.length; - const separatorLabel = Previewer.plain(item.separatorDisplayParts); + const separatorLabel = Previewer.plainWithLinks(item.separatorDisplayParts, this.client); for (let i = 0; i < item.parameters.length; ++i) { const parameter = item.parameters[i]; - const label = Previewer.plain(parameter.displayParts); + const label = Previewer.plainWithLinks(parameter.displayParts, this.client); signature.parameters.push( new vscode.ParameterInformation( [textIndex, textIndex + label.length], - Previewer.markdownDocumentation(parameter.documentation, []))); + Previewer.markdownDocumentation(parameter.documentation, [], this.client))); textIndex += label.length; signature.label += label; @@ -95,7 +95,7 @@ class TypeScriptSignatureHelpProvider implements vscode.SignatureHelpProvider { } } - signature.label += Previewer.plain(item.suffixDisplayParts); + signature.label += Previewer.plainWithLinks(item.suffixDisplayParts, this.client); return signature; } } diff --git a/extensions/typescript-language-features/src/languageProvider.ts b/extensions/typescript-language-features/src/languageProvider.ts index 2cb36eb948b..3af617eafd6 100644 --- a/extensions/typescript-language-features/src/languageProvider.ts +++ b/extensions/typescript-language-features/src/languageProvider.ts @@ -70,7 +70,7 @@ export default class LanguageProvider extends Disposable { import('./languageFeatures/fixAll').then(provider => this._register(provider.register(selector, this.client, this.fileConfigurationManager, this.client.diagnosticsManager))), import('./languageFeatures/folding').then(provider => this._register(provider.register(selector, this.client))), import('./languageFeatures/formatting').then(provider => this._register(provider.register(selector, this.description.id, this.client, this.fileConfigurationManager))), - import('./languageFeatures/hover').then(provider => this._register(provider.register(selector, this.client))), + import('./languageFeatures/hover').then(provider => this._register(provider.register(selector, this.client, this.fileConfigurationManager))), import('./languageFeatures/implementations').then(provider => this._register(provider.register(selector, this.client))), import('./languageFeatures/jsDocCompletions').then(provider => this._register(provider.register(selector, this.description.id, this.client, this.fileConfigurationManager))), import('./languageFeatures/organizeImports').then(provider => this._register(provider.register(selector, this.client, this.commandManager, this.fileConfigurationManager, this.telemetryReporter))), diff --git a/extensions/typescript-language-features/src/protocol.d.ts b/extensions/typescript-language-features/src/protocol.d.ts index 9cf1f851e48..853fd543f34 100644 --- a/extensions/typescript-language-features/src/protocol.d.ts +++ b/extensions/typescript-language-features/src/protocol.d.ts @@ -10,5 +10,9 @@ declare module 'typescript/lib/protocol' { interface Response { readonly _serverType?: ServerType; } + + interface JSDocLinkDisplayPart { + target: Proto.FileSpan; + } } diff --git a/extensions/typescript-language-features/src/test/unit/previewer.test.ts b/extensions/typescript-language-features/src/test/unit/previewer.test.ts index 38bfc14ecbb..bdbbf103b80 100644 --- a/extensions/typescript-language-features/src/test/unit/previewer.test.ts +++ b/extensions/typescript-language-features/src/test/unit/previewer.test.ts @@ -5,7 +5,12 @@ import * as assert from 'assert'; import 'mocha'; -import { tagsMarkdownPreview, markdownDocumentation } from '../../utils/previewer'; +import { Uri } from 'vscode'; +import { tagsMarkdownPreview, markdownDocumentation, IFilePathToResourceConverter } from '../../utils/previewer'; + +const noopToResource: IFilePathToResourceConverter = { + toResource: (path) => Uri.file(path) +}; suite('typescript.previewer', () => { test('Should ignore hyphens after a param tag', async () => { @@ -15,25 +20,37 @@ suite('typescript.previewer', () => { name: 'param', text: 'a - b' } - ]), + ], noopToResource), '*@param* `a` — b'); }); test('Should parse url jsdoc @link', async () => { assert.strictEqual( - markdownDocumentation('x {@link http://www.example.com/foo} y {@link https://api.jquery.com/bind/#bind-eventType-eventData-handler} z', []).value, + markdownDocumentation( + 'x {@link http://www.example.com/foo} y {@link https://api.jquery.com/bind/#bind-eventType-eventData-handler} z', + [], + noopToResource + ).value, 'x [http://www.example.com/foo](http://www.example.com/foo) y [https://api.jquery.com/bind/#bind-eventType-eventData-handler](https://api.jquery.com/bind/#bind-eventType-eventData-handler) z'); }); test('Should parse url jsdoc @link with text', async () => { assert.strictEqual( - markdownDocumentation('x {@link http://www.example.com/foo abc xyz} y {@link http://www.example.com/bar|b a z} z', []).value, + markdownDocumentation( + 'x {@link http://www.example.com/foo abc xyz} y {@link http://www.example.com/bar|b a z} z', + [], + noopToResource + ).value, 'x [abc xyz](http://www.example.com/foo) y [b a z](http://www.example.com/bar) z'); }); test('Should treat @linkcode jsdocs links as monospace', async () => { assert.strictEqual( - markdownDocumentation('x {@linkcode http://www.example.com/foo} y {@linkplain http://www.example.com/bar} z', []).value, + markdownDocumentation( + 'x {@linkcode http://www.example.com/foo} y {@linkplain http://www.example.com/bar} z', + [], + noopToResource + ).value, 'x [`http://www.example.com/foo`](http://www.example.com/foo) y [http://www.example.com/bar](http://www.example.com/bar) z'); }); @@ -44,13 +61,17 @@ suite('typescript.previewer', () => { name: 'param', text: 'a x {@link http://www.example.com/foo abc xyz} y {@link http://www.example.com/bar|b a z} z' } - ]), + ], noopToResource), '*@param* `a` — x [abc xyz](http://www.example.com/foo) y [b a z](http://www.example.com/bar) z'); }); test('Should ignore unclosed jsdocs @link', async () => { assert.strictEqual( - markdownDocumentation('x {@link http://www.example.com/foo y {@link http://www.example.com/bar bar} z', []).value, + markdownDocumentation( + 'x {@link http://www.example.com/foo y {@link http://www.example.com/bar bar} z', + [], + noopToResource + ).value, 'x {@link http://www.example.com/foo y [bar](http://www.example.com/bar) z'); }); @@ -61,7 +82,7 @@ suite('typescript.previewer', () => { name: 'param', text: 'parámetroConDiacríticos this will not' } - ]), + ], noopToResource), '*@param* `parámetroConDiacríticos` — this will not'); }); }); diff --git a/extensions/typescript-language-features/src/utils/previewer.ts b/extensions/typescript-language-features/src/utils/previewer.ts index 3d059dca331..cfbbfe4e78e 100644 --- a/extensions/typescript-language-features/src/utils/previewer.ts +++ b/extensions/typescript-language-features/src/utils/previewer.ts @@ -6,6 +6,13 @@ import * as vscode from 'vscode'; import type * as Proto from '../protocol'; +export interface IFilePathToResourceConverter { + /** + * Convert a typescript filepath to a VS Code resource. + */ + toResource(filepath: string): vscode.Uri; +} + function replaceLinks(text: string): string { return text // Http(s) links @@ -24,7 +31,10 @@ function processInlineTags(text: string): string { return replaceLinks(text); } -function getTagBodyText(tag: Proto.JSDocTagInfo): string | undefined { +function getTagBodyText( + tag: Proto.JSDocTagInfo, + filePathConverter: IFilePathToResourceConverter, +): string | undefined { if (!tag.text) { return undefined; } @@ -37,38 +47,42 @@ function getTagBodyText(tag: Proto.JSDocTagInfo): string | undefined { return '```\n' + text + '\n```'; } + const text = convertLinkTags(tag.text, filePathConverter); switch (tag.name) { case 'example': // check for caption tags, fix for #79704 - const captionTagMatches = tag.text.match(/(.*?)<\/caption>\s*(\r\n|\n)/); + const captionTagMatches = text.match(/(.*?)<\/caption>\s*(\r\n|\n)/); if (captionTagMatches && captionTagMatches.index === 0) { - return captionTagMatches[1] + '\n\n' + makeCodeblock(tag.text.substr(captionTagMatches[0].length)); + return captionTagMatches[1] + '\n\n' + makeCodeblock(text.substr(captionTagMatches[0].length)); } else { - return makeCodeblock(tag.text); + return makeCodeblock(text); } case 'author': // fix obsucated email address, #80898 - const emailMatch = tag.text.match(/(.+)\s<([-.\w]+@[-.\w]+)>/); + const emailMatch = text.match(/(.+)\s<([-.\w]+@[-.\w]+)>/); if (emailMatch === null) { - return tag.text; + return text; } else { return `${emailMatch[1]} ${emailMatch[2]}`; } case 'default': - return makeCodeblock(tag.text); + return makeCodeblock(text); } - return processInlineTags(tag.text); + return processInlineTags(text); } -function getTagDocumentation(tag: Proto.JSDocTagInfo): string | undefined { +function getTagDocumentation( + tag: Proto.JSDocTagInfo, + filePathConverter: IFilePathToResourceConverter, +): string | undefined { switch (tag.name) { case 'augments': case 'extends': case 'param': case 'template': - const body = (tag.text || '').split(/^(\S+)\s*-?\s*/); + const body = (convertLinkTags(tag.text, filePathConverter)).split(/^(\S+)\s*-?\s*/); if (body?.length === 3) { const param = body[1]; const doc = body[2]; @@ -82,44 +96,112 @@ function getTagDocumentation(tag: Proto.JSDocTagInfo): string | undefined { // Generic tag const label = `*@${tag.name}*`; - const text = getTagBodyText(tag); + const text = getTagBodyText(tag, filePathConverter); if (!text) { return label; } return label + (text.match(/\r\n|\n/g) ? ' \n' + text : ` — ${text}`); } -export function plain(parts: Proto.SymbolDisplayPart[] | string): string { - return processInlineTags( - typeof parts === 'string' - ? parts - : parts.map(part => part.text).join('')); +export function plainWithLinks( + parts: readonly Proto.SymbolDisplayPart[] | string, + filePathConverter: IFilePathToResourceConverter, +): string { + return processInlineTags(convertLinkTags(parts, filePathConverter)); } -export function tagsMarkdownPreview(tags: Proto.JSDocTagInfo[]): string { - return tags.map(getTagDocumentation).join(' \n\n'); +/** + * Convert `@link` inline tags to markdown links + */ +function convertLinkTags( + parts: readonly Proto.SymbolDisplayPart[] | string | undefined, + filePathConverter: IFilePathToResourceConverter, +): string { + if (!parts) { + return ''; + } + + if (typeof parts === 'string') { + return parts; + } + + const out: string[] = []; + + let currentLink: { name?: string, target?: Proto.FileSpan, text?: string } | undefined; + for (const part of parts) { + switch (part.kind) { + case 'link': + if (currentLink) { + const text = currentLink.text ?? currentLink.name; + if (currentLink.target) { + const link = filePathConverter.toResource(currentLink.target.file) + .with({ + fragment: `L${currentLink.target.start.line},${currentLink.target.start.offset}` + }); + + out.push(`[${text}](${link.toString(true)})`); + } else { + if (text) { + out.push(text); + } + } + currentLink = undefined; + } else { + currentLink = {}; + } + break; + + case 'linkName': + if (currentLink) { + currentLink.name = part.text; + // TODO: remove cast once we pick up TS 4.3 + currentLink.target = (part as any as Proto.JSDocLinkDisplayPart).target; + } + break; + + case 'linkText': + if (currentLink) { + currentLink.text = part.text; + } + break; + + default: + out.push(part.text); + break; + } + } + return processInlineTags(out.join('')); +} + +export function tagsMarkdownPreview( + tags: readonly Proto.JSDocTagInfo[], + filePathConverter: IFilePathToResourceConverter, +): string { + return tags.map(tag => getTagDocumentation(tag, filePathConverter)).join(' \n\n'); } export function markdownDocumentation( documentation: Proto.SymbolDisplayPart[] | string, - tags: Proto.JSDocTagInfo[] + tags: Proto.JSDocTagInfo[], + filePathConverter: IFilePathToResourceConverter, ): vscode.MarkdownString { const out = new vscode.MarkdownString(); - addMarkdownDocumentation(out, documentation, tags); + addMarkdownDocumentation(out, documentation, tags, filePathConverter); return out; } export function addMarkdownDocumentation( out: vscode.MarkdownString, documentation: Proto.SymbolDisplayPart[] | string | undefined, - tags: Proto.JSDocTagInfo[] | undefined + tags: Proto.JSDocTagInfo[] | undefined, + converter: IFilePathToResourceConverter, ): vscode.MarkdownString { if (documentation) { - out.appendMarkdown(plain(documentation)); + out.appendMarkdown(plainWithLinks(documentation, converter)); } if (tags) { - const tagsPreview = tagsMarkdownPreview(tags); + const tagsPreview = tagsMarkdownPreview(tags, converter); if (tagsPreview) { out.appendMarkdown('\n\n' + tagsPreview); } diff --git a/src/vs/vscode.d.ts b/src/vs/vscode.d.ts index 390bd33d733..f47529e92b1 100644 --- a/src/vs/vscode.d.ts +++ b/src/vs/vscode.d.ts @@ -2419,13 +2419,13 @@ declare module 'vscode' { /** * Information about where a symbol is defined. * - * Provides additional metadata over normal [location](#Location) definitions, including the range of + * Provides additional metadata over normal {@link Location location} definitions, including the range of * the defining symbol */ export type DefinitionLink = LocationLink; /** - * The definition of a symbol represented as one or many [locations](#Location). + * The definition of a symbol represented as one or many {@link Location locations}. * For most programming languages there is only one location at which a symbol is * defined. */