From 83edc7f120d244f6b1f667d2ad6f80e1747a581c Mon Sep 17 00:00:00 2001 From: charburgx Date: Fri, 21 Oct 2022 05:22:25 -0500 Subject: [PATCH] feat(vscode): jsdoc support --- packages/typescript-explorer/src/markdown.ts | 237 ++++++++++++++++++ .../src/view/typeTreeView.ts | 16 +- 2 files changed, 251 insertions(+), 2 deletions(-) create mode 100644 packages/typescript-explorer/src/markdown.ts diff --git a/packages/typescript-explorer/src/markdown.ts b/packages/typescript-explorer/src/markdown.ts new file mode 100644 index 0000000..12ba2b3 --- /dev/null +++ b/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>\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, '\\$&'); +} diff --git a/packages/typescript-explorer/src/view/typeTreeView.ts b/packages/typescript-explorer/src/view/typeTreeView.ts index 890bab5..b99f7b8 100644 --- a/packages/typescript-explorer/src/view/typeTreeView.ts +++ b/packages/typescript-explorer/src/view/typeTreeView.ts @@ -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 @@ -20,7 +21,18 @@ export class TypeTreeProvider implements vscode.TreeDataProvider { 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 }