import {parse as jsdocTypePrattParse} from 'jsdoc-type-pratt-parser'; /** * Removes initial and ending brackets from `rawType` * @param {JsdocTypeLine[]|JsdocTag} container * @param {boolean} [isArr] * @returns {void} */ const stripEncapsulatingBrackets = (container, isArr) => { if (isArr) { const firstItem = /** @type {JsdocTypeLine[]} */ (container)[0]; firstItem.rawType = firstItem.rawType.replace( /^\{/u, '' ); const lastItem = /** @type {JsdocTypeLine} */ ( /** @type {JsdocTypeLine[]} */ ( container ).at(-1) ); lastItem.rawType = lastItem.rawType.replace(/\}$/u, ''); return; } /** @type {JsdocTag} */ (container).rawType = /** @type {JsdocTag} */ (container).rawType.replace( /^\{/u, '' ).replace(/\}$/u, ''); }; /** * @typedef {{ * delimiter: string, * postDelimiter: string, * rawType: string, * initial: string, * type: "JsdocTypeLine" * }} JsdocTypeLine */ /** * @typedef {{ * delimiter: string, * description: string, * postDelimiter: string, * initial: string, * type: "JsdocDescriptionLine" * }} JsdocDescriptionLine */ /** * @typedef {{ * format: 'pipe' | 'plain' | 'prefix' | 'space', * namepathOrURL: string, * tag: string, * text: string, * }} JsdocInlineTagNoType */ /** * @typedef {JsdocInlineTagNoType & { * type: "JsdocInlineTag" * }} JsdocInlineTag */ /** * @typedef {{ * delimiter: string, * description: string, * descriptionLines: JsdocDescriptionLine[], * initial: string, * inlineTags: JsdocInlineTag[] * name: string, * postDelimiter: string, * postName: string, * postTag: string, * postType: string, * rawType: string, * parsedType: import('jsdoc-type-pratt-parser').RootResult|null * tag: string, * type: "JsdocTag", * typeLines: JsdocTypeLine[], * }} JsdocTag */ /** * @typedef {number} Integer */ /** * @typedef {{ * delimiter: string, * delimiterLineBreak: string, * description: string, * descriptionEndLine?: Integer, * descriptionLines: JsdocDescriptionLine[], * descriptionStartLine?: Integer, * hasPreterminalDescription: 0|1, * hasPreterminalTagDescription?: 1, * initial: string, * inlineTags: JsdocInlineTag[] * lastDescriptionLine?: Integer, * endLine: Integer, * lineEnd: string, * postDelimiter: string, * tags: JsdocTag[], * terminal: string, * preterminalLineBreak: string, * type: "JsdocBlock", * }} JsdocBlock */ /** * @param {object} cfg * @param {string} cfg.text * @param {string} cfg.tag * @param {'pipe' | 'plain' | 'prefix' | 'space'} cfg.format * @param {string} cfg.namepathOrURL * @returns {JsdocInlineTag} */ const inlineTagToAST = ({text, tag, format, namepathOrURL}) => ({ text, tag, format, namepathOrURL, type: 'JsdocInlineTag' }); /** * Converts comment parser AST to ESTree format. * @param {import('.').JsdocBlockWithInline} jsdoc * @param {import('jsdoc-type-pratt-parser').ParseMode} mode * @param {object} opts * @param {'compact'|'preserve'} [opts.spacing] By default, empty lines are * compacted; set to 'preserve' to preserve empty comment lines. * @param {boolean} [opts.throwOnTypeParsingErrors] * @returns {JsdocBlock} */ const commentParserToESTree = (jsdoc, mode, { spacing = 'compact', throwOnTypeParsingErrors = false } = {}) => { /** * Strips brackets from a tag's `rawType` values and adds `parsedType` * @param {JsdocTag} lastTag * @returns {void} */ const cleanUpLastTag = (lastTag) => { // Strip out `}` that encapsulates and is not part of // the type stripEncapsulatingBrackets(lastTag); if (lastTag.typeLines.length) { stripEncapsulatingBrackets(lastTag.typeLines, true); } // Remove single empty line description. if (lastTag.descriptionLines.length === 1 && lastTag.descriptionLines[0].description === '') { lastTag.descriptionLines.length = 0; } // With even a multiline type now in full, add parsing let parsedType = null; try { parsedType = jsdocTypePrattParse(lastTag.rawType, mode); } catch (err) { // Ignore if (lastTag.rawType && throwOnTypeParsingErrors) { /** @type {Error} */ ( err ).message = `Tag @${lastTag.tag} with raw type ` + `\`${lastTag.rawType}\` had parsing error: ${ /** @type {Error} */ (err).message}`; throw err; } } lastTag.parsedType = parsedType; }; const {source, inlineTags: blockInlineTags} = jsdoc; const {tokens: { delimiter: delimiterRoot, lineEnd: lineEndRoot, postDelimiter: postDelimiterRoot, start: startRoot, end: endRoot }} = source[0]; const endLine = source.length - 1; /** @type {JsdocBlock} */ const ast = { delimiter: delimiterRoot, delimiterLineBreak: '\n', description: '', descriptionLines: [], inlineTags: blockInlineTags.map((t) => inlineTagToAST(t)), initial: startRoot, tags: [], // `terminal` will be overwritten if there are other entries terminal: endRoot, preterminalLineBreak: '\n', hasPreterminalDescription: 0, endLine, postDelimiter: postDelimiterRoot, lineEnd: lineEndRoot, type: 'JsdocBlock' }; /** * @type {JsdocTag[]} */ const tags = []; /** @type {Integer|undefined} */ let lastDescriptionLine; /** @type {JsdocTag|null} */ let lastTag = null; // Tracks when first valid tag description line is seen. let tagDescriptionSeen = false; let descLineStateOpen = true; source.forEach((info, idx) => { const {tokens} = info; const { delimiter, description, postDelimiter, start: initial, tag, end, type: rawType } = tokens; if (!tag && description && descLineStateOpen) { if (ast.descriptionStartLine === undefined) { ast.descriptionStartLine = idx; } ast.descriptionEndLine = idx; } if (tag || end) { descLineStateOpen = false; if (lastDescriptionLine === undefined) { lastDescriptionLine = idx; } // Clean-up with last tag before end or new tag if (lastTag) { cleanUpLastTag(lastTag); } // Stop the iteration when we reach the end // but only when there is no tag earlier in the line // to still process if (end && !tag) { ast.terminal = end; // Check if there are any description lines and if not then this is a // one line comment block. const isDelimiterLine = ast.descriptionLines.length === 0 && delimiter === '/**'; // Remove delimiter line break for one line comments blocks. if (isDelimiterLine) { ast.delimiterLineBreak = ''; } if (description) { // Remove terminal line break at end when description is defined. if (ast.terminal === '*/') { ast.preterminalLineBreak = ''; } if (lastTag) { ast.hasPreterminalTagDescription = 1; } else { ast.hasPreterminalDescription = 1; } const holder = lastTag || ast; holder.description += (holder.description ? '\n' : '') + description; // Do not include `delimiter` / `postDelimiter` for opening // delimiter line. holder.descriptionLines.push({ delimiter: isDelimiterLine ? '' : delimiter, description, postDelimiter: isDelimiterLine ? '' : postDelimiter, initial, type: 'JsdocDescriptionLine' }); } return; } const { // eslint-disable-next-line no-unused-vars -- Discarding end: ed, delimiter: de, postDelimiter: pd, start: init, ...tkns } = tokens; if (!tokens.name) { let i = 1; while (source[idx + i]) { const {tokens: { name, postName, postType, tag: tg }} = source[idx + i]; if (tg) { break; } if (name) { tkns.postType = postType; tkns.name = name; tkns.postName = postName; break; } i++; } } /** * @type {JsdocInlineTag[]} */ let tagInlineTags = []; if (tag) { // Assuming the tags from `source` are in the same order as `jsdoc.tags` // we can use the `tags` length as index into the parser result tags. tagInlineTags = /** * @type {import('comment-parser').Spec & { * inlineTags: JsdocInlineTagNoType[] * }} */ ( jsdoc.tags[tags.length] ).inlineTags.map( (t) => inlineTagToAST(t) ); } /** @type {JsdocTag} */ const tagObj = { ...tkns, initial: endLine ? init : '', postDelimiter: lastDescriptionLine ? pd : '', delimiter: lastDescriptionLine ? de : '', descriptionLines: [], inlineTags: tagInlineTags, parsedType: null, rawType: '', type: 'JsdocTag', typeLines: [] }; tagObj.tag = tagObj.tag.replace(/^@/u, ''); lastTag = tagObj; tagDescriptionSeen = false; tags.push(tagObj); } if (rawType) { // Will strip rawType brackets after this tag /** @type {JsdocTag} */ (lastTag).typeLines.push( /** @type {JsdocTag} */ (lastTag).typeLines.length ? { delimiter, postDelimiter, rawType, initial, type: 'JsdocTypeLine' } : { delimiter: '', postDelimiter: '', rawType, initial: '', type: 'JsdocTypeLine' } ); /** @type {JsdocTag} */ (lastTag).rawType += /** @type {JsdocTag} */ ( lastTag ).rawType ? '\n' + rawType : rawType; } // In `compact` mode skip processing if `description` is an empty string // unless lastTag is being processed. // // In `preserve` mode process when `description` is not the `empty string // or the `delimiter` is not `/**` ensuring empty lines are preserved. if (((spacing === 'compact' && description) || lastTag) || (spacing === 'preserve' && (description || delimiter !== '/**'))) { const holder = lastTag || ast; // Check if there are any description lines and if not then this is a // multi-line comment block with description on 0th line. Treat // `delimiter` / `postDelimiter` / `initial` as being on a new line. const isDelimiterLine = holder.descriptionLines.length === 0 && delimiter === '/**'; // Remove delimiter line break for one line comments blocks. if (isDelimiterLine) { ast.delimiterLineBreak = ''; } // Track when the first description line is seen to avoid adding empty // description lines for tag type lines. tagDescriptionSeen ||= Boolean(lastTag && (rawType === '' || rawType?.endsWith('}'))); if (lastTag) { if (tagDescriptionSeen) { // The first tag description line is a continuation after type / // name parsing. const isFirstDescriptionLine = holder.descriptionLines.length === 0; // For `compact` spacing must allow through first description line. if ((spacing === 'compact' && (description || isFirstDescriptionLine)) || spacing === 'preserve') { holder.descriptionLines.push({ delimiter: isFirstDescriptionLine ? '' : delimiter, description, postDelimiter: isFirstDescriptionLine ? '' : postDelimiter, initial: isFirstDescriptionLine ? '' : initial, type: 'JsdocDescriptionLine' }); } } } else { holder.descriptionLines.push({ delimiter: isDelimiterLine ? '' : delimiter, description, postDelimiter: isDelimiterLine ? '' : postDelimiter, initial: isDelimiterLine ? `` : initial, type: 'JsdocDescriptionLine' }); } if (!tag) { if (lastTag) { // For `compact` spacing must filter out any empty description lines // after the initial `holder.description` has content. if (tagDescriptionSeen && !(spacing === 'compact' && holder.description && description === '')) { holder.description += !holder.description ? description : '\n' + description; } } else { holder.description += !holder.description ? description : '\n' + description; } } } // Clean-up where last line itself has tag content if (end && tag) { ast.terminal = end; ast.hasPreterminalTagDescription = 1; // Remove terminal line break at end when tag is defined on last line. if (ast.terminal === '*/') { ast.preterminalLineBreak = ''; } cleanUpLastTag(/** @type {JsdocTag} */ (lastTag)); } }); ast.lastDescriptionLine = lastDescriptionLine; ast.tags = tags; return ast; }; const jsdocVisitorKeys = { JsdocBlock: ['descriptionLines', 'tags', 'inlineTags'], JsdocDescriptionLine: [], JsdocTypeLine: [], JsdocTag: ['parsedType', 'typeLines', 'descriptionLines', 'inlineTags'], JsdocInlineTag: [] }; export {commentParserToESTree, jsdocVisitorKeys};