Add support for JSDoc link tags (#119358)

* Add support for JSDoc @link tags

Fixes #119357

* Fix unresolved links not rendering
This commit is contained in:
Matt Bierner 2021-03-29 11:29:52 -07:00 committed by GitHub
parent a573d500d0
commit a39120ddd1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 168 additions and 51 deletions

View file

@ -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;
}

View file

@ -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<boolean>('suggest.includeAutomaticOptionalChainCompletions', true),
provideRefactorNotApplicableReason: true,
generateReturnInDocTemplate: config.get<boolean>('suggest.jsdoc.generateReturns', true),
displayPartsForJSDoc: true,
};
return preferences;

View file

@ -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));
});
}

View file

@ -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;
}
}

View file

@ -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))),

View file

@ -10,5 +10,9 @@ declare module 'typescript/lib/protocol' {
interface Response {
readonly _serverType?: ServerType;
}
interface JSDocLinkDisplayPart {
target: Proto.FileSpan;
}
}

View file

@ -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');
});
});

View file

@ -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>(.*?)<\/caption>\s*(\r\n|\n)/);
const captionTagMatches = text.match(/<caption>(.*?)<\/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);
}

4
src/vs/vscode.d.ts vendored
View file

@ -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.
*/