From 270547ee206f6105dd935d2fb8aa347b47ab4a60 Mon Sep 17 00:00:00 2001 From: Segev Finer Date: Sat, 12 Jan 2019 13:59:27 +0200 Subject: [PATCH] Format go doc comments as intended rather than treat them as Markdown Fixes #1486 --- package-lock.json | 35 +++- package.json | 2 + src/goExtraInfo.ts | 6 +- src/godocToMarkdown.ts | 359 +++++++++++++++++++++++++++++++++++++++++ src/util.ts | 3 +- 5 files changed, 400 insertions(+), 5 deletions(-) create mode 100644 src/godocToMarkdown.ts diff --git a/package-lock.json b/package-lock.json index fd41da0b7..86c7845b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,6 +4,15 @@ "lockfileVersion": 1, "requires": true, "dependencies": { + "@babel/runtime-corejs2": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs2/-/runtime-corejs2-7.2.0.tgz", + "integrity": "sha512-kPfmKoRI8Hpo5ZJGACWyrc9Eq1j3ZIUpUAQT2yH045OuYpccFJ9kYA/eErwzOM2jeBG1sC8XX1nl1EArtuM8tg==", + "requires": { + "core-js": "^2.5.7", + "regenerator-runtime": "^0.12.0" + } + }, "@types/fs-extra": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-5.0.4.tgz", @@ -25,6 +34,12 @@ "integrity": "sha512-JWB3xaVfsfnFY8Ofc9rTB/op0fqqTSqy4vBcVk1LuRJvta7KTX+D//fCkiTMeLGhdr2EbFZzQjC97gvmPilk9Q==", "dev": true }, + "@types/xregexp": { + "version": "3.0.29", + "resolved": "https://registry.npmjs.org/@types/xregexp/-/xregexp-3.0.29.tgz", + "integrity": "sha512-mm6iZYQ1xbVBNsWq2VSMFuneRuO0k0wUqIT4ZfrtbD1Eb90DXmqBOPA/URyUHq6wsftxr8aXDJHTTHyyBBY95w==", + "dev": true + }, "ajv": { "version": "6.6.2", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.6.2.tgz", @@ -395,6 +410,11 @@ "safe-buffer": "~5.1.1" } }, + "core-js": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.2.tgz", + "integrity": "sha512-NdBPF/RVwPW6jr0NCILuyN9RiqLo2b1mddWHkUL+VnvcB7dzlnBJ1bXYntjpTGOgkZiiLWj2JxmOr7eGE3qK6g==" + }, "core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", @@ -525,7 +545,7 @@ }, "event-stream": { "version": "3.3.4", - "resolved": "https://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz", + "resolved": "http://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz", "integrity": "sha1-SrTJoPWlTbkzi0w02Gv86PSzVXE=", "dev": true, "requires": { @@ -1558,6 +1578,11 @@ "util-deprecate": "~1.0.1" } }, + "regenerator-runtime": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz", + "integrity": "sha512-odxIc1/vDlo4iZcfXqRYFj0vpXFNoGdKMAUieAlFYO6m/nl5e9KR/beGf41z4a1FI+aQgtjhuaSlDxQ0hmkrHg==" + }, "remove-bom-buffer": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/remove-bom-buffer/-/remove-bom-buffer-3.0.0.tgz", @@ -2184,6 +2209,14 @@ "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", "dev": true }, + "xregexp": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/xregexp/-/xregexp-4.2.4.tgz", + "integrity": "sha512-sO0bYdYeJAJBcJA8g7MJJX7UrOZIfJPd8U2SC7B2Dd/J24U0aQNoGp33shCaBSWeb0rD5rh6VBUIXOkGal1TZA==", + "requires": { + "@babel/runtime-corejs2": "^7.2.0" + } + }, "xtend": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz", diff --git a/package.json b/package.json index b938ca0c1..492137bc5 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "dependencies": { "diff": "^3.5.0", "json-rpc2": "^1.0.2", + "xregexp": "^4.2.4", "vscode-debugadapter": "^1.32.1", "vscode-debugprotocol": "^1.32.0", "vscode-extension-telemetry": "^0.1.0", @@ -46,6 +47,7 @@ "@types/fs-extra": "^5.0.4", "@types/mocha": "^5.2.5", "@types/node": "^6.14.0", + "@types/xregexp": "^3.0.29", "fs-extra": "^7.0.0", "tslint": "^5.11.0", "typescript": "^3.1.3", diff --git a/src/goExtraInfo.ts b/src/goExtraInfo.ts index 28b0c70aa..b9367f0b8 100644 --- a/src/goExtraInfo.ts +++ b/src/goExtraInfo.ts @@ -33,10 +33,10 @@ export class GoHoverProvider implements HoverProvider { .map(line => line.replace(/\t/g, ' ')); let text; text = lines.join('\n').replace(/\n+$/, ''); - let hoverTexts: MarkedString[] = []; - hoverTexts.push({ language: 'go', value: text }); + let hoverTexts = new vscode.MarkdownString(); + hoverTexts.appendCodeblock(text, 'go'); if (definitionInfo.doc != null) { - hoverTexts.push(definitionInfo.doc); + hoverTexts.appendMarkdown(definitionInfo.doc); } let hover = new Hover(hoverTexts); return hover; diff --git a/src/godocToMarkdown.ts b/src/godocToMarkdown.ts new file mode 100644 index 000000000..42bca45f3 --- /dev/null +++ b/src/godocToMarkdown.ts @@ -0,0 +1,359 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------*/ + +import * as XRegExp from 'XRegExp'; + +enum Op { + Para, + Head, + Pre, +} + +interface Block { + op: Op; + lines: string[]; +} + +function markdownEscape(text: string): string { + return text.replace(/([\\`*{}[\]()#+-.!_>~|\n"$%&',\/:;<=?@^])/g, '\\$1'); +} + +// Escape comment text for Markdown. If nice is set, +// also turn `` into '“' and '' into '”'. +function commentEscape(text: string, nice: boolean): string { + if (nice) { + return markdownEscape(convertQuotes(text)); + } + return markdownEscape(text); +} + +function convertQuotes(text: string): string { + return text.replace('``', '“').replace('\'\'', '”'); +} + +// Regexp for Go identifiers +const identRx = '[\\p{L}_][\\p{L}_0-9]*'; + +// Regexp for URLs +// Match parens, and check later for balance - see #5043, #22285 +// Match .,:;?! within path, but not at end - see #18139, #16565 +// This excludes some rare yet valid urls ending in common punctuation +// in order to allow sentences ending in URLs. + +// protocol (required) e.g. http +const protoPart = '(https?|ftp|file|gopher|mailto|nntp)'; +// host (required) e.g. www.example.com or [::1]:8080 +const hostPart = '([a-zA-Z0-9_@\\-.\\[\\]:]+)'; +// path+query+fragment (optional) e.g. /path/index.html?q=foo#bar +const pathPart = '([.,:;?!]*[a-zA-Z0-9$\'()*+&#=@~_/\\-\\[\\]%])*'; + +const urlRx = protoPart + `://` + hostPart + pathPart; + +const matchRx = '(' + urlRx + ')|(' + identRx + ')'; + +function emphasize(line: string, words: {string: string}, nice: boolean): string { + const parts = []; + + const regexp = XRegExp(matchRx, 'gu'); + while (true) { + let m = XRegExp.exec(line, regexp); + if (m == null) { + break; + } + // m >= 6 (two parenthesized sub-regexps in matchRx, 1st one is urlRx) + + // Write text before match + parts.push(commentEscape(line.slice(0, m.index) , nice)); + + let match = m[0]; + if (match.includes('://')) { + let m0 = m.index, m1 = m[0].length; + for (const s of [['(', ')'], ['{', '}'], ['[', ']']]) { + const open = s[0], close = s[1]; + // Require opening parentheses before closing parentheses (#22285) + const i = match.indexOf(close); + if (i >= 0 && i < match.indexOf(open)) { + m1 = m0 + i; + match = line.slice(m0, m1); + } + // Require balanced pairs of parentheses (#5043) + for (let i = 0; (XRegExp.match(match, XRegExp('\\' + open, 'g'))).length !== (XRegExp.match(match, XRegExp('\\' + close, 'g'))).length && i < 10; i++) { + const shortedLine = line.slice(0, m1); + const parenRegex = XRegExp(`[\\${s[0]}\\${s[1]}]`, 'g'); + let prevMatch2 = null; + while (true) { + const match2 = XRegExp.exec(shortedLine, parenRegex); + if (match2 == null) { + m1 = prevMatch2.index; + break; + } + prevMatch2 = match2; + } + } + } + if (m1 !== m[0].length) { + // Redo matching with shortened line for correct indices + m = XRegExp.exec(line.slice(0, m.index + match.length), XRegExp(matchRx, 'g')); + } + } + + // Analyze match + let url = ''; + let italics = false; + if (words != null) { + if (words.hasOwnProperty(match)) { + url = words[match]; + italics = true; + } + } + if (m[1] != null) { + // Match against first parenthesized sub-regexp; must be match against urlRx + if (!italics) { + // No alternative URL in words list, use match instead + url = match; + } + italics = false; // Don't italicize URLs + } + + // Write match + if (url.length > 0) { + parts.push('['); + } + if (italics) { + parts.push('*'); + } + parts.push(commentEscape(match, nice)); + if (italics) { + parts.push('*'); + } + if (url.length > 0) { + parts.push('](', url.replace(')', '\\)'), ')'); + } + + // Advance + line = line.slice(m.index + m[0].length); + } + parts.push(commentEscape(line, nice)); + + return parts.join(''); +} + +function indentLen(s: string): number { + let i = 0; + while (i < s.length && (s[i] === ' ' || s[i] === '\t')) { + i++; + } + return i; +} + +function isBlank(s: string): boolean { + return s.length === 0 || (s.length === 1 && s[0] === '\n'); +} + +function commonPrefix(a: string, b: string): string { + let i = 0; + while (i < a.length && i < b.length && a[i] === b[i]) { + i++; + } + return a.slice(0, i); +} + +function unindent(block: string[]) { + if (block.length === 0) { + return; + } + + // Compute maximum common white prefix + let prefix = block[0].slice(0, indentLen(block[0])); + for (const line of block) { + if (!isBlank(line)) { + prefix = commonPrefix(prefix, line.slice(0, indentLen(line))); + } + } + const n = prefix.length; + + // Remove + for (let i = 0; i < block.length; i++) { + const line = block[i]; + if (!isBlank(line)) { + block[i] = line.slice(n); + } + } +} + +// heading returns the trimmed line if it passes as a section heading; +// otherwise it returns the empty string. +function heading(line: string): string { + line = line.trim(); + if (line.length === 0) { + return ''; + } + + // A heading must start with an uppercase letter + let r = line.charAt(0); + if (!XRegExp('\\p{Lu}', 'u').test(r)) { + return ''; + } + + // It must end in a letter or digit: + r = line.charAt(line.length - 1); + if (!XRegExp('\\p{L}|\\p{Nd}', 'u').test(r)) { + return ''; + } + + // Exclude lines with illegal characters. we allow "()," + if (XRegExp('[;:!?+*/=[\\]{}_^°&§~%#@<">\\\\]').test(line)) { + return ''; + } + + // Allow "'" for possessive "'s" only + for (let b = line; ;) { + const i = b.indexOf('\''); + if (i < 0) { + break; + } + if (i + 1 >= b.length || b[i + 1] !== 's' || (i + 2 < b.length && b[i + 2] !== ' ')) { + return ''; // not followed by "s " + } + b = b.slice(i + 2); + } + + // allow "." when followed by non-space + for (let b = line; ;) { + let i = b.indexOf('.'); + if (i < 0) { + break; + } + if (i + 1 >= b.length || b[i + 1] === ' ') { + return ''; // not followed by non-space + } + b = b.slice(i + 1); + } + + return line; +} + +function* blocks(text: string): IterableIterator { + let block; + let para = []; + let lastWasBlank = false; + let lastWasHeading = false; + + function close() { + if (para.length > 0) { + const block = {op: Op.Para, lines: para}; + para = []; + return block; + } + + return null; + } + + const lines = text.split('\n'); + unindent(lines); + for (let i = 0; i < lines.length;) { + const line = lines[i]; + if (isBlank(line)) { + // Close paragraph + if ((block = close()) != null) { + yield block; + } + i++; + lastWasBlank = true; + continue; + } + if (indentLen(line) > 0) { + // Close paragraph + if ((block = close()) != null) { + yield block; + } + + // Count indented or blank lines + let j = i + 1; + while (j < lines.length && (isBlank(lines[j]) || indentLen(lines[j]) > 0)) { + j++; + } + // But not trailing blank lines + while (j > i && isBlank(lines[j - 1])) { + j--; + } + const pre = lines.slice(i, j); + i = j; + + unindent(pre); + + // Put those lines in a pre block + yield {op: Op.Pre, lines: pre}; + lastWasHeading = false; + continue; + } + + if (lastWasBlank && !lastWasHeading && i + 2 < lines.length && + isBlank(lines[i + 1]) && !isBlank(lines[i + 2]) && indentLen(lines[i + 2]) === 0) { + // Current line is non-blank, surrounded by blank lines + // and the next non-blank line is not indented: this + // might be a heading. + const head = heading(line); + if (head !== '') { + if ((block = close()) != null) { + yield block; + } + yield {op: Op.Head, lines: [head]}; + i += 2; + lastWasHeading = true; + continue; + } + } + + // Open paragraph + lastWasBlank = false; + lastWasHeading = false; + para.push(lines[i]); + i++; + } + if ((block = close()) != null) { + yield block; + } +} + +export default function godocToMarkdown(text: string, words: {string: string}): string { + const parts = []; + let printed = false; + + for (const b of blocks(text)) { + switch (b.op) { + case Op.Para: + if (printed) { + parts.push('\n'); + } + for (const line of b.lines) { + parts.push(emphasize(line, words, true) + '\n'); + } + break; + case Op.Head: + parts.push('\n'); + if (printed) { + parts.push('\n'); + } + for (const line of b.lines) { + parts.push('### ' + commentEscape(line, true) + '\n'); + } + break; + case Op.Pre: + parts.push('\n'); + if (printed) { + parts.push('\n'); + } + for (const line of b.lines) { + parts.push(' ', emphasize(line, null, false) + '\n'); + } + parts.push('\n'); + break; + } + printed = true; + } + + return parts.join(''); +} diff --git a/src/util.ts b/src/util.ts index c8de20829..a055dbf7c 100644 --- a/src/util.ts +++ b/src/util.ts @@ -14,6 +14,7 @@ import { outputChannel } from './goStatus'; import { NearestNeighborDict, Node } from './avlTree'; import { getCurrentPackage } from './goModules'; import { buildDiagnosticCollection, lintDiagnosticCollection, vetDiagnosticCollection } from './goMain'; +import godocToMarkdown from './godocToMarkdown'; const extensionId: string = 'ms-vscode.Go'; const extensionVersion: string = vscode.extensions.getExtension(extensionId).packageJSON.version; @@ -937,7 +938,7 @@ export function runGodoc(packagePath: string, receiver: string, symbol: string, doc += '\n'; } } - return resolve(doc); + return resolve(godocToMarkdown(doc, null)); }); if (token) {