Skip to content

Commit

Permalink
feat(vscode): jsdoc support
Browse files Browse the repository at this point in the history
  • Loading branch information
mxsdev committed Oct 21, 2022
1 parent ac72e65 commit 83edc7f
Show file tree
Hide file tree
Showing 2 changed files with 251 additions and 2 deletions.
237 changes: 237 additions & 0 deletions packages/typescript-explorer/src/markdown.ts
@@ -0,0 +1,237 @@
/**
* https://github.com/microsoft/vscode/blob/129f5bc976847bf9a54ca918c5fde86fd9fc0a84/extensions/typescript-language-features/src/utils/previewer.ts
*/

/*---------------------------------------------------------------------------------------------
* 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 Proto from 'typescript/lib/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
.replace(/\{@(link|linkplain|linkcode) (https?:\/\/[^ |}]+?)(?:[| ]([^{}\n]+?))?\}/gi, (_, tag: string, link: string, text?: string) => {
switch (tag) {
case 'linkcode':
return `[\`${text ? text.trim() : link}\`](${link})`;

default:
return `[${text ? text.trim() : link}](${link})`;
}
});
}

function processInlineTags(text: string): string {
return replaceLinks(text);
}

function getTagBodyText(
tag: Proto.JSDocTagInfo,
filePathConverter: IFilePathToResourceConverter,
): string | undefined {
if (!tag.text) {
return undefined;
}

// Convert to markdown code block if it does not already contain one
function makeCodeblock(text: string): string {
if (/^\s*[~`]{3}/m.test(text)) {
return text;
}
return '```\n' + text + '\n```';
}

const text = convertLinkTags(tag.text, filePathConverter);
switch (tag.name) {
case 'example': {
// check for caption tags, fix for #79704
const captionTagMatches = text.match(/<caption>(.*?)<\/caption>\s*(\r\n|\n)/);
if (captionTagMatches && captionTagMatches.index === 0) {
return captionTagMatches[1] + '\n' + makeCodeblock(text.substr(captionTagMatches[0].length));
} else {
return makeCodeblock(text);
}
}
case 'author': {
// fix obsucated email address, #80898
const emailMatch = text.match(/(.+)\s<([-.\w]+@[-.\w]+)>/);

if (emailMatch === null) {
return text;
} else {
return `${emailMatch[1]} ${emailMatch[2]}`;
}
}
case 'default':
return makeCodeblock(text);
}

return processInlineTags(text);
}

function getTagDocumentation(
tag: Proto.JSDocTagInfo,
filePathConverter: IFilePathToResourceConverter,
): string | undefined {
switch (tag.name) {
case 'augments':
case 'extends':
case 'param':
case 'template': {
const body = (convertLinkTags(tag.text, filePathConverter)).split(/^(\S+)\s*-?\s*/);
if (body?.length === 3) {
const param = body[1];
const doc = body[2];
const label = `*@${tag.name}* \`${param}\``;
if (!doc) {
return label;
}
return label + (doc.match(/\r\n|\n/g) ? ' \n' + processInlineTags(doc) : ` \u2014 ${processInlineTags(doc)}`);
}
}
}

// Generic tag
const label = `*@${tag.name}*`;
const text = getTagBodyText(tag, filePathConverter);
if (!text) {
return label;
}
return label + (text.match(/\r\n|\n/g) ? ' \n' + text : ` \u2014 ${text}`);
}

export function plainWithLinks(
parts: readonly Proto.SymbolDisplayPart[] | string,
filePathConverter: IFilePathToResourceConverter,
): string {
return processInlineTags(convertLinkTags(parts, filePathConverter));
}

/**
* 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; readonly linkcode: boolean } | undefined;
for (const part of parts) {
switch (part.kind) {
case 'link':
if (currentLink) {
if (currentLink.target) {
const link = filePathConverter.toResource(currentLink.target.file)
.with({
fragment: `L${currentLink.target.start.line},${currentLink.target.start.offset}`
});

const linkText = currentLink.text ? currentLink.text : escapeMarkdownSyntaxTokensForCode(currentLink.name ?? '');
out.push(`[${currentLink.linkcode ? '`' + linkText + '`' : linkText}](${link.toString()})`);
} else {
const text = currentLink.text ?? currentLink.name;
if (text) {
if (/^https?:/.test(text)) {
const parts = text.split(' ');
if (parts.length === 1) {
out.push(parts[0]);
} else if (parts.length > 1) {
const linkText = escapeMarkdownSyntaxTokensForCode(parts.slice(1).join(' '));
out.push(`[${currentLink.linkcode ? '`' + linkText + '`' : linkText}](${parts[0]})`);
}
} else {
out.push(escapeMarkdownSyntaxTokensForCode(text));
}
}
}
currentLink = undefined;
} else {
currentLink = {
linkcode: part.text === '{@linkcode '
};
}
break;

case 'linkName':
if (currentLink) {
currentLink.name = part.text;
currentLink.target = (part 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[],
// filePathConverter: IFilePathToResourceConverter,
baseUri: vscode.Uri | undefined,
): vscode.MarkdownString {
const filePathConverter = { toResource: (filePath: string) => vscode.Uri.file(filePath) }

const out = new vscode.MarkdownString();
addMarkdownDocumentation(out, documentation, tags, filePathConverter);
out.baseUri = baseUri;
return out;
}

export function addMarkdownDocumentation(
out: vscode.MarkdownString,
documentation: Proto.SymbolDisplayPart[] | string | undefined,
tags: Proto.JSDocTagInfo[] | undefined,
converter: IFilePathToResourceConverter,
): vscode.MarkdownString {
if (documentation) {
out.appendMarkdown(plainWithLinks(documentation, converter));
}

if (tags) {
const tagsPreview = tagsMarkdownPreview(tags, converter);
if (tagsPreview) {
out.appendMarkdown('\n\n' + tagsPreview);
}
}
return out;
}

function escapeMarkdownSyntaxTokensForCode(text: string): string {
return text.replace(/`/g, '\\$&');
}
16 changes: 14 additions & 2 deletions packages/typescript-explorer/src/view/typeTreeView.ts
Expand Up @@ -2,8 +2,9 @@ import { TypeInfo, TypeId, getTypeInfoChildren, SymbolInfo, SignatureInfo, Index
import assert = require('assert');
import * as vscode from 'vscode'
import { TSExplorer } from '../config';
import { markdownDocumentation } from '../markdown';
import { StateManager } from '../state/stateManager';
import { fromFileLocationRequestArgs, rangeFromLineAndCharacters } from '../util';
import { fromFileLocationRequestArgs, getQuickInfoAtPosition, rangeFromLineAndCharacters } from '../util';

const { None: NoChildren, Expanded, Collapsed } = vscode.TreeItemCollapsibleState

Expand All @@ -20,7 +21,18 @@ export class TypeTreeProvider implements vscode.TreeDataProvider<TypeTreeItem> {
this._onDidChangeTreeData.fire()
}

getTreeItem(element: TypeTreeItem) {
async getTreeItem(element: TypeTreeItem) {
if(element.typeInfo.locations) {
for(const location of element.typeInfo.locations) {
const { documentation, tags } = await getQuickInfoAtPosition(location.fileName, location.range.start) ?? { }

if(documentation) {
element.tooltip = markdownDocumentation(documentation, tags ?? [], vscode.Uri.file(location.fileName))
break
}
}
}

return element
}

Expand Down

0 comments on commit 83edc7f

Please sign in to comment.