@compodoc/compodoc
Version:
The missing documentation tool for your Angular application
413 lines (370 loc) • 14.5 kB
text/typescript
import * as _ from './collection.util';
import { ts, SyntaxKind } from "ts-morph";
import * as _ts from "./ts-internal";
/**
* Standard JSDoc tags whose inline text should NOT be folded into the description.
* For any other (custom) tag, text on the same line as the tag is included.
*/
const SKIP_TAG_INLINE_CONTENT = new Set([
"param",
"arg",
"argument",
"parameter",
"returns",
"return",
"throws",
"exception",
"throw",
"type",
"typedef",
"template",
"deprecated",
"example",
"see",
"ignore",
"internal",
]);
export class JsdocParserUtil {
public isVariableLike(node: ts.Node): node is ts.VariableLikeDeclaration {
if (node) {
switch (node.kind) {
case SyntaxKind.BindingElement:
case SyntaxKind.EnumMember:
case SyntaxKind.Parameter:
case SyntaxKind.PropertyAssignment:
case SyntaxKind.PropertyDeclaration:
case SyntaxKind.PropertySignature:
case SyntaxKind.ShorthandPropertyAssignment:
case SyntaxKind.VariableDeclaration:
return true;
}
}
return false;
}
isTopmostModuleDeclaration(node: ts.ModuleDeclaration): boolean {
if (
(node as any).nextContainer &&
(node as any).nextContainer.kind === ts.SyntaxKind.ModuleDeclaration
) {
const next = <ts.ModuleDeclaration>(node as any).nextContainer;
if (node.name.end + 1 === next.name.pos) {
return false;
}
}
return true;
}
getRootModuleDeclaration(node: ts.ModuleDeclaration): ts.Node {
while (
node.parent &&
node.parent.kind === ts.SyntaxKind.ModuleDeclaration
) {
let parent = <ts.ModuleDeclaration>node.parent;
if (node.name.pos === parent.name.end + 1) {
node = parent;
} else {
break;
}
}
return node;
}
public getMainCommentOfNode(
node: ts.Node,
sourceFile?: ts.SourceFile,
): string {
let description: string = "";
if (
node.parent &&
node.parent.kind === ts.SyntaxKind.VariableDeclarationList
) {
node = node.parent.parent;
} else if (node.kind === ts.SyntaxKind.ModuleDeclaration) {
if (!this.isTopmostModuleDeclaration(<ts.ModuleDeclaration>node)) {
return null;
} else {
node = this.getRootModuleDeclaration(
<ts.ModuleDeclaration>node,
);
}
}
const comments = _ts.getJSDocCommentRanges(
node as any,
sourceFile.text,
);
if (comments && comments.length) {
let comment: ts.CommentRange;
if (node.kind === ts.SyntaxKind.SourceFile) {
if (comments.length === 1) {
return null;
}
comment = comments[0];
} else {
comment = comments[comments.length - 1];
}
description = sourceFile.text.substring(comment.pos, comment.end);
}
return description;
}
public parseComment(text: string): string {
let comment = "";
let shortText = 0;
function readBareLine(line: string) {
comment += "\n" + line;
if (line === "" && shortText === 0) {
// Ignore
} else if (line === "" && shortText === 1) {
shortText = 2;
} else {
if (shortText === 2) {
comment += (comment === "" ? "" : "\n") + line;
}
}
}
const CODE_FENCE = /^\s*```(?!.*```)/;
let inCode = false;
let inExample = false; // first line with @example, end line with empty string or string or */
let exampleHasCodeFence = false; // track if the example already has code fences
function readLine(line: string, index: number) {
const originalLine = line;
line = line.replace(/^\s*\*? ?/, "");
line = line.replace(/\s*$/, "");
if (CODE_FENCE.test(line)) {
inCode = !inCode;
if (inExample) {
exampleHasCodeFence = true;
}
}
if (line.indexOf("@example") !== -1) {
inExample = true;
exampleHasCodeFence = false;
// Check if the next non-empty line has a code fence
const lines = text.split(/\r\n?|\n/);
for (let i = index + 1; i < lines.length; i++) {
const nextLine = lines[i]
.replace(/^\s*\*? ?/, "")
.replace(/\s*$/, "");
if (nextLine === "") continue; // Skip empty lines
if (CODE_FENCE.test(nextLine)) {
exampleHasCodeFence = true;
}
break; // Only check the first non-empty line
}
if (!exampleHasCodeFence) {
line = "```html";
} else {
// Skip the @example line if it already has code fences
return;
}
}
// Preserve empty lines within code blocks by using a placeholder
if (inCode && inExample && exampleHasCodeFence && line === "") {
line = "___COMPODOC_EMPTY_LINE___";
}
if (inExample && line === "") {
inExample = false;
if (!exampleHasCodeFence) {
line = "```";
} else {
// Don't add closing fence if example has its own
return;
}
}
if (!inCode) {
const tag = /^@(\S+)/.exec(line);
const SeeTag = /^@see/.exec(line);
if (SeeTag) {
line = line.replace(/^@see/, "See");
}
if (tag && !SeeTag) {
const tagName = tag[1].toLowerCase();
if (!SKIP_TAG_INLINE_CONTENT.has(tagName)) {
const textAfterTag = line.slice(tag[0].length).trim();
if (textAfterTag) {
readBareLine(textAfterTag);
}
}
return;
}
}
readBareLine(line);
}
text = text.replace(/^\s*\/\*+/, "");
text = text.replace(/\*+\/\s*$/, "");
text.split(/\r\n?|\n/).forEach((line, index) => readLine(line, index));
return comment;
}
private getJSDocTags(node: ts.Node, kind: SyntaxKind): ts.JSDocTag[] {
const docs = this.getJSDocs(node);
if (docs) {
const result: ts.JSDocTag[] = [];
for (const doc of docs) {
if (ts.isJSDocParameterTag(doc)) {
if (doc.kind === kind) {
result.push(doc);
}
} else if (ts.isJSDoc(doc)) {
result.push(
..._.filter(doc.tags, (tag) => tag.kind === kind),
);
} else if ((doc as any) && (doc as any).kind === kind) {
result.push(doc as ts.JSDocTag);
} else if (
(doc as any) &&
typeof (doc as any).kind === "number"
) {
// Ignore other JSDoc node kinds to avoid hard crashes on TS AST variations.
continue;
} else {
throw new Error("Unexpected type");
}
}
return result;
}
}
public getJSDocs(node: ts.Node): ReadonlyArray<ts.JSDoc | ts.JSDocTag> {
// TODO: jsDocCache is internal, see if there's a way around it
let cache: ReadonlyArray<ts.JSDoc | ts.JSDocTag> = (node as any)
.jsDocCache;
if (!cache) {
cache = this.getJSDocsWorker(node, []).filter((x) => x);
(node as any).jsDocCache = cache;
}
return cache;
}
// Try to recognize this pattern when node is initializer
// of variable declaration and JSDoc comments are on containing variable statement.
// /**
// * @param {number} name
// * @returns {number}
// */
// var x = function(name) { return name.length; }
private getJSDocsWorker(node: ts.Node, cache): ReadonlyArray<any> {
const parent = node.parent;
const isInitializerOfVariableDeclarationInStatement =
this.isVariableLike(parent) &&
(parent as any).initializer === node &&
ts.isVariableStatement(parent.parent.parent);
const isVariableOfVariableDeclarationStatement =
this.isVariableLike(node) && ts.isVariableStatement(parent.parent);
const variableStatementNode =
isInitializerOfVariableDeclarationInStatement
? parent.parent.parent
: isVariableOfVariableDeclarationStatement
? parent.parent
: undefined;
if (variableStatementNode) {
cache = this.getJSDocsWorker(variableStatementNode, cache);
}
// Also recognize when the node is the RHS of an assignment expression
const isSourceOfAssignmentExpressionStatement =
parent &&
parent.parent &&
ts.isBinaryExpression(parent) &&
parent.operatorToken.kind === SyntaxKind.EqualsToken &&
ts.isExpressionStatement(parent.parent);
if (isSourceOfAssignmentExpressionStatement) {
cache = this.getJSDocsWorker(parent.parent, cache);
}
const isModuleDeclaration =
ts.isModuleDeclaration(node) &&
parent &&
ts.isModuleDeclaration(parent);
const isPropertyAssignmentExpression =
parent && ts.isPropertyAssignment(parent);
if (isModuleDeclaration || isPropertyAssignmentExpression) {
cache = this.getJSDocsWorker(parent, cache);
}
// Pull parameter comments from declaring function as well
if (ts.isParameter(node)) {
cache = _.concat(cache, this.getJSDocParameterTags(node));
}
if (this.isVariableLike(node) && (node as any).initializer) {
const initializerJsDoc = ((node as any).initializer as any).jsDoc;
if (initializerJsDoc) {
cache = _.concat(cache, initializerJsDoc);
}
}
const nodeJsDoc = (node as any).jsDoc;
if (nodeJsDoc) {
cache = _.concat(cache, nodeJsDoc);
}
return cache;
}
private getJSDocParameterTags(
param: ts.ParameterDeclaration,
): ReadonlyArray<ts.JSDocParameterTag> {
const func = param.parent as ts.FunctionLikeDeclaration;
const tags = this.getJSDocTags(
func,
SyntaxKind.JSDocParameterTag,
) as ts.JSDocParameterTag[];
if (!param.name) {
// this is an anonymous jsdoc param from a `function(type1, type2): type3` specification
const i = func.parameters.indexOf(param);
const paramTags = _.filter(tags, (tag) =>
ts.isJSDocParameterTag(tag),
);
if (paramTags && 0 <= i && i < paramTags.length) {
return [paramTags[i]];
}
} else if (ts.isIdentifier(param.name)) {
const name = param.name.text;
return _.filter(tags, (tag) => {
if (ts && ts.isJSDocParameterTag(tag)) {
const t = tag as any;
if (typeof t.parameterName !== "undefined") {
return t.parameterName.text === name;
} else if (typeof t.name !== "undefined") {
if (typeof t.name.escapedText !== "undefined") {
return t.name.escapedText === name;
}
}
}
});
} else {
// TODO: it's a destructured parameter, so it should look up an "object type" series of multiple lines
// But multi-line object types aren't supported yet either
return undefined;
}
}
public parseJSDocNode(node): string {
let rawDescription = "";
if (typeof node.comment === "string") {
rawDescription += node.comment;
} else {
if (node.comment) {
const len = node.comment.length;
for (let i = 0; i < len; i++) {
const JSDocNode = node.comment[i];
switch (JSDocNode.kind) {
case SyntaxKind.JSDocComment:
rawDescription += JSDocNode.comment;
break;
case SyntaxKind.JSDocText:
rawDescription += JSDocNode.text;
break;
case SyntaxKind.JSDocLink:
if (JSDocNode.name) {
let text = JSDocNode.name.escapedText;
if (
text === undefined &&
JSDocNode.name.left &&
JSDocNode.name.right
) {
text =
JSDocNode.name.left.escapedText +
"." +
JSDocNode.name.right.escapedText;
}
rawDescription +=
JSDocNode.text + "{@link " + text + "}";
}
break;
default:
break;
}
}
}
}
return rawDescription;
}
}