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:
parent
a573d500d0
commit
a39120ddd1
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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))),
|
||||
|
|
|
@ -10,5 +10,9 @@ declare module 'typescript/lib/protocol' {
|
|||
interface Response {
|
||||
readonly _serverType?: ServerType;
|
||||
}
|
||||
|
||||
interface JSDocLinkDisplayPart {
|
||||
target: Proto.FileSpan;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
4
src/vs/vscode.d.ts
vendored
|
@ -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.
|
||||
*/
|
||||
|
|
Loading…
Reference in a new issue