"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.parseComment = void 0; const assert_1 = require("assert"); const models_1 = require("../../models"); const utils_1 = require("../../utils"); const paths_1 = require("../../utils/paths"); const lexer_1 = require("./lexer"); const tagName_1 = require("./tagName"); function makeLookaheadGenerator(gen) { let trackHistory = false; const history = []; const next = [gen.next()]; return { done() { return !!next[0].done; }, peek() { (0, assert_1.ok)(!next[0].done); return next[0].value; }, take() { const thisItem = next.shift(); if (trackHistory) { history.push(thisItem); } (0, assert_1.ok)(!thisItem.done); next.push(gen.next()); return thisItem.value; }, mark() { (0, assert_1.ok)(!trackHistory, "Can only mark one location for backtracking at a time"); trackHistory = true; }, release() { trackHistory = false; next.unshift(...history); history.length = 0; }, }; } function parseComment(tokens, config, file, logger) { const lexer = makeLookaheadGenerator(tokens); const tok = lexer.done() || lexer.peek(); const comment = new models_1.Comment(); comment.summary = blockContent(comment, lexer, config, warningImpl); while (!lexer.done()) { comment.blockTags.push(blockTag(comment, lexer, config, warningImpl)); } postProcessComment(comment, (message) => { (0, assert_1.ok)(typeof tok === "object"); logger.warn(`${message} in comment at ${(0, paths_1.nicePath)(file.fileName)}:${file.getLineAndCharacterOfPosition(tok.pos).line + 1}`); }); return comment; function warningImpl(message, token) { logger.warn(message, token.pos, file); } } exports.parseComment = parseComment; const HAS_USER_IDENTIFIER = [ "@callback", "@param", "@prop", "@property", "@template", "@typedef", "@typeParam", "@inheritDoc", ]; function makeCodeBlock(text) { return "```ts\n" + text + "\n```"; } /** * Loop over comment, produce lint warnings, and set tag names for tags * which have them. */ function postProcessComment(comment, warning) { for (const tag of comment.blockTags) { if (HAS_USER_IDENTIFIER.includes(tag.tag) && tag.content.length) { const first = tag.content[0]; if (first.kind === "text") { const { name, newText } = (0, tagName_1.extractTagName)(first.text); tag.name = name; if (newText) { first.text = newText; } else { // Remove this token, no real text in it. tag.content.shift(); } } } if (tag.content.some((part) => part.kind === "inline-tag" && part.tag === "@inheritDoc")) { warning("An inline @inheritDoc tag should not appear within a block tag as it will not be processed"); } } const remarks = comment.blockTags.filter((tag) => tag.tag === "@remarks"); if (remarks.length > 1) { warning("At most one @remarks tag is expected in a comment, ignoring all but the first"); (0, utils_1.removeIf)(comment.blockTags, (tag) => remarks.indexOf(tag) > 0); } const returns = comment.blockTags.filter((tag) => tag.tag === "@returns"); if (remarks.length > 1) { warning("At most one @returns tag is expected in a comment, ignoring all but the first"); (0, utils_1.removeIf)(comment.blockTags, (tag) => returns.indexOf(tag) > 0); } const inheritDoc = comment.blockTags.filter((tag) => tag.tag === "@inheritDoc"); const inlineInheritDoc = comment.summary.filter((part) => part.kind === "inline-tag" && part.tag === "@inheritDoc"); if (inlineInheritDoc.length + inheritDoc.length > 1) { warning("At most one @inheritDoc tag is expected in a comment, ignoring all but the first"); const allInheritTags = [...inlineInheritDoc, ...inheritDoc]; (0, utils_1.removeIf)(comment.summary, (part) => allInheritTags.indexOf(part) > 0); (0, utils_1.removeIf)(comment.blockTags, (tag) => allInheritTags.indexOf(tag) > 0); } if ((inlineInheritDoc.length || inheritDoc.length) && comment.summary.some((part) => part.kind !== "inline-tag" && /\S/.test(part.text))) { warning("Content in the summary section will be overwritten by the @inheritDoc tag"); } if ((inlineInheritDoc.length || inheritDoc.length) && remarks.length) { warning("Content in the @remarks block will be overwritten by the @inheritDoc tag"); } } const aliasedTags = new Map([["@return", "@returns"]]); function blockTag(comment, lexer, config, warning) { const blockTag = lexer.take(); (0, assert_1.ok)(blockTag.kind === lexer_1.TokenSyntaxKind.Tag, "blockTag called not at the start of a block tag."); // blockContent is broken if this fails. const tagName = aliasedTags.get(blockTag.text) || blockTag.text; let content; if (tagName === "@example") { return exampleBlock(comment, lexer, config, warning); } else if (["@default", "@defaultValue"].includes(tagName) && config.jsDocCompatibility.defaultTag) { content = defaultBlockContent(comment, lexer, config, warning); } else { content = blockContent(comment, lexer, config, warning); } return new models_1.CommentTag(tagName, content); } /** * The `@default` tag gets a special case because otherwise we will produce many warnings * about unescaped/mismatched/missing braces in legacy JSDoc comments */ function defaultBlockContent(comment, lexer, config, warning) { lexer.mark(); const content = blockContent(comment, lexer, config, () => { }); const end = lexer.done() || lexer.peek(); lexer.release(); if (content.some((part) => part.kind === "code")) { return blockContent(comment, lexer, config, warning); } const tokens = []; while ((lexer.done() || lexer.peek()) !== end) { tokens.push(lexer.take()); } const blockText = tokens .map((tok) => tok.text) .join("") .trim(); return [ { kind: "code", text: makeCodeBlock(blockText), }, ]; } /** * The `@example` tag gets a special case because otherwise we will produce many warnings * about unescaped/mismatched/missing braces in legacy JSDoc comments. * * In TSDoc, we also want to treat the first line of the block as the example name. */ function exampleBlock(comment, lexer, config, warning) { lexer.mark(); const content = blockContent(comment, lexer, config, () => { }); const end = lexer.done() || lexer.peek(); lexer.release(); if (!config.jsDocCompatibility.exampleTag || content.some((part) => part.kind === "code" && part.text.startsWith("```"))) { let exampleName = ""; // First line of @example block is the example name. let warnedAboutRichNameContent = false; outer: while ((lexer.done() || lexer.peek()) !== end) { const next = lexer.peek(); switch (next.kind) { case lexer_1.TokenSyntaxKind.NewLine: lexer.take(); break outer; case lexer_1.TokenSyntaxKind.Text: { const newline = next.text.indexOf("\n"); if (newline !== -1) { exampleName += next.text.substring(0, newline); next.pos += newline + 1; break outer; } else { exampleName += lexer.take().text; } break; } case lexer_1.TokenSyntaxKind.Code: case lexer_1.TokenSyntaxKind.Tag: case lexer_1.TokenSyntaxKind.TypeAnnotation: case lexer_1.TokenSyntaxKind.CloseBrace: case lexer_1.TokenSyntaxKind.OpenBrace: if (!warnedAboutRichNameContent) { warning("The first line of an example tag will be taken literally as" + " the example name, and should only contain text.", lexer.peek()); warnedAboutRichNameContent = true; } exampleName += lexer.take().text; break; default: (0, utils_1.assertNever)(next.kind); } } const content = blockContent(comment, lexer, config, warning); const tag = new models_1.CommentTag("@example", content); if (exampleName.trim()) { tag.name = exampleName.trim(); } return tag; } const tokens = []; while ((lexer.done() || lexer.peek()) !== end) { tokens.push(lexer.take()); } const blockText = tokens .map((tok) => tok.text) .join("") .trim(); const caption = blockText.match(/^\s*(.*?)<\/caption>\s*(\n|$)/); if (caption) { const tag = new models_1.CommentTag("@example", [ { kind: "code", text: makeCodeBlock(blockText.slice(caption[0].length)), }, ]); tag.name = caption[1]; return tag; } else { return new models_1.CommentTag("@example", [ { kind: "code", text: makeCodeBlock(blockText), }, ]); } } function blockContent(comment, lexer, config, warning) { const content = []; let atNewLine = true; loop: while (!lexer.done()) { const next = lexer.peek(); let consume = true; switch (next.kind) { case lexer_1.TokenSyntaxKind.NewLine: case lexer_1.TokenSyntaxKind.Text: content.push({ kind: "text", text: next.text }); break; case lexer_1.TokenSyntaxKind.Code: content.push({ kind: "code", text: next.text }); break; case lexer_1.TokenSyntaxKind.Tag: if (next.text === "@inheritdoc") { if (!config.jsDocCompatibility.inheritDocTag) { warning("The @inheritDoc tag should be properly capitalized", next); } next.text = "@inheritDoc"; } if (config.modifierTags.has(next.text)) { comment.modifierTags.add(next.text); break; } else if (!atNewLine && !config.blockTags.has(next.text)) { // Treat unknown tag as a modifier, but warn about it. comment.modifierTags.add(next.text); warning(`Treating unrecognized tag "${next.text}" as a modifier tag`, next); break; } else { // Block tag or unknown tag, handled by our caller. break loop; } case lexer_1.TokenSyntaxKind.TypeAnnotation: // We always ignore these. In TS files they are redundant, in JS files // they are required. break; case lexer_1.TokenSyntaxKind.CloseBrace: // Unmatched closing brace, generate a warning, and treat it as text. if (!config.jsDocCompatibility.ignoreUnescapedBraces) { warning(`Unmatched closing brace`, next); } content.push({ kind: "text", text: next.text }); break; case lexer_1.TokenSyntaxKind.OpenBrace: inlineTag(lexer, content, config, warning); consume = false; break; default: (0, utils_1.assertNever)(next.kind); } if (consume && lexer.take().kind === lexer_1.TokenSyntaxKind.NewLine) { atNewLine = true; } } // Collapse adjacent text parts for (let i = 0; i < content.length - 1 /* inside loop */;) { if (content[i].kind === "text" && content[i + 1].kind === "text") { content[i].text += content[i + 1].text; content.splice(i + 1, 1); } else { i++; } } // Now get rid of extra whitespace, and any empty parts for (let i = 0; i < content.length /* inside loop */;) { if (i === 0 || content[i].kind === "inline-tag") { content[i].text = content[i].text.trimStart(); } if (i === content.length - 1 || content[i].kind === "inline-tag") { content[i].text = content[i].text.trimEnd(); } if (!content[i].text && content[i].kind === "text") { content.splice(i, 1); } else { i++; } } return content; } function inlineTag(lexer, block, config, warning) { const openBrace = lexer.take(); // Now skip whitespace to grab the tag name. // If the first non-whitespace text after the brace isn't a tag, // then produce a warning and treat what we've consumed as plain text. if (lexer.done() || ![lexer_1.TokenSyntaxKind.Text, lexer_1.TokenSyntaxKind.Tag].includes(lexer.peek().kind)) { if (!config.jsDocCompatibility.ignoreUnescapedBraces) { warning("Encountered an unescaped open brace without an inline tag", openBrace); } block.push({ kind: "text", text: openBrace.text }); return; } let tagName = lexer.take(); if (lexer.done() || (tagName.kind === lexer_1.TokenSyntaxKind.Text && (!/^\s+$/.test(tagName.text) || lexer.peek().kind != lexer_1.TokenSyntaxKind.Tag))) { if (!config.jsDocCompatibility.ignoreUnescapedBraces) { warning("Encountered an unescaped open brace without an inline tag", openBrace); } block.push({ kind: "text", text: openBrace.text + tagName.text }); return; } if (tagName.kind !== lexer_1.TokenSyntaxKind.Tag) { tagName = lexer.take(); } if (!config.inlineTags.has(tagName.text)) { warning(`Encountered an unknown inline tag "${tagName.text}"`, tagName); } const content = []; // At this point, we know we have an inline tag. Treat everything following as plain text, // until we get to the closing brace. while (!lexer.done() && lexer.peek().kind !== lexer_1.TokenSyntaxKind.CloseBrace) { const token = lexer.take(); if (token.kind === lexer_1.TokenSyntaxKind.OpenBrace) { warning("Encountered an open brace within an inline tag, this is likely a mistake", token); } content.push(token.kind === lexer_1.TokenSyntaxKind.NewLine ? " " : token.text); } if (lexer.done()) { warning("Inline tag is not closed", openBrace); } else { lexer.take(); // Close brace } const inlineTag = { kind: "inline-tag", tag: tagName.text, text: content.join(""), }; if (tagName.tsLinkTarget) { inlineTag.target = tagName.tsLinkTarget; inlineTag.tsLinkText = tagName.tsLinkText; } block.push(inlineTag); }