/** * Utilities for parsing TSDoc comments. * @internal */ import { TSDocParser, TSDocConfiguration, TextRange, ParserContext, DocComment, } from '@microsoft/tsdoc' import type { TSESTree } from '@typescript-eslint/utils' import type { ReleaseTag } from '../types' /** * TSDoc parser instance configured for API Extractor compatibility. */ let parserInstance: TSDocParser | undefined /** * Gets or creates a TSDoc parser instance. */ function getParser(): TSDocParser { if (!parserInstance) { const config = new TSDocConfiguration() // API Extractor's custom tags are defined via tsdoc.json extends // For our purposes, the default configuration suffices as we're // checking for standard modifier tags parserInstance = new TSDocParser(config) } return parserInstance } /** * Parses a TSDoc comment string. * * @param commentText - The full comment text including delimiters * @returns Parser context with the parsed doc comment * @alpha */ export function parseTSDocComment(commentText: string): ParserContext { const parser = getParser() const textRange = TextRange.fromString(commentText) return parser.parseRange(textRange) } /** * Extracts a release tag from a parsed TSDoc comment. * * @param docComment - The parsed doc comment * @returns The release tag if found, undefined otherwise * @alpha */ export function extractReleaseTag( docComment: DocComment, ): ReleaseTag | undefined { // Check for modifier tags if (docComment.modifierTagSet.isPublic()) { return 'public' } if (docComment.modifierTagSet.isBeta()) { return 'beta' } if (docComment.modifierTagSet.isAlpha()) { return 'alpha' } if (docComment.modifierTagSet.isInternal()) { return 'internal' } return undefined } /** * Checks if a TSDoc comment has the @override tag. * * @param docComment - The parsed doc comment * @returns True if @override tag is present * @alpha */ export function hasOverrideTag(docComment: DocComment): boolean { return docComment.modifierTagSet.isOverride() } /** * Checks if a TSDoc comment has the @packageDocumentation tag. * * @param docComment - The parsed doc comment * @returns True if @packageDocumentation tag is present * @alpha */ export function hasPackageDocumentation(docComment: DocComment): boolean { return docComment.modifierTagSet.isPackageDocumentation() } /** * Enum type values for `@enumType` tag. * @alpha */ export type EnumTypeValue = 'open' | 'closed' /** * Result of extracting `@enumType` tag from a TSDoc comment. * @alpha */ export interface EnumTypeExtraction { /** Whether any `@enumType` tags were found */ found: boolean /** Number of `@enumType` tags found */ count: number /** The extracted value (lowercase), or undefined if missing/invalid */ value?: string /** Whether the value is valid ('open' or 'closed') */ isValid: boolean /** The original raw value from the comment */ rawValue?: string } /** * Extracts `@enumType` tag information from a TSDoc comment. * * @param commentText - The full comment text including delimiters * @returns Extraction result with tag information * @alpha */ export function extractEnumType(commentText: string): EnumTypeExtraction { const result: EnumTypeExtraction = { found: false, count: 0, isValid: false, } // Match @enumType followed by optional whitespace and a word (letters/numbers only) // The value must be a word-like identifier, not * or other punctuation // We use [ \t]+ instead of \s+ to avoid capturing across newlines const enumTypeRegex = /@enumType(?:[ \t]+([a-zA-Z][a-zA-Z0-9]*))?/gi let match: RegExpExecArray | null while ((match = enumTypeRegex.exec(commentText)) !== null) { result.count++ result.found = true // Only capture the first occurrence's value if (result.count === 1) { result.rawValue = match[1] if (match[1]) { const lowerValue = match[1].toLowerCase() result.value = lowerValue result.isValid = lowerValue === 'open' || lowerValue === 'closed' } } } return result } /** * Checks if a comment is a block comment (TSDoc style). */ function isBlockComment(comment: TSESTree.Comment): boolean { // TSESTree.Comment.type is 'Line' | 'Block' - comparing to string literal // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison return comment.type === 'Block' } /** * Gets the leading comment for a node, if it's a TSDoc comment. * * @param sourceCode - ESLint source code object * @param node - The AST node to check * @returns The comment text if a TSDoc comment exists, undefined otherwise * @alpha */ export function getLeadingTSDocComment( sourceCode: { getCommentsBefore: (node: TSESTree.Node) => TSESTree.Comment[] }, node: TSESTree.Node, ): string | undefined { const comments = sourceCode.getCommentsBefore(node) if (comments.length === 0) { return undefined } // Get the last comment before the node (closest to it) const lastComment = comments[comments.length - 1] if (!lastComment) { return undefined } // TSDoc comments must be block comments starting with /** if (!isBlockComment(lastComment)) { return undefined } // Check if it's a TSDoc comment (starts with *) const value = lastComment.value if (!value.startsWith('*')) { return undefined } // Reconstruct the full comment return `/*${value}*/` } /** * Finds all TSDoc comments in a source file. * * @param sourceCode - ESLint source code object * @returns Array of comment objects with their parsed content * @alpha */ export function findAllTSDocComments(sourceCode: { getAllComments: () => TSESTree.Comment[] }): Array<{ comment: TSESTree.Comment; parsed: ParserContext }> { const results: Array<{ comment: TSESTree.Comment; parsed: ParserContext }> = [] for (const comment of sourceCode.getAllComments()) { if (!isBlockComment(comment) || !comment.value.startsWith('*')) { continue } const commentText = `/*${comment.value}*/` const parsed = parseTSDocComment(commentText) results.push({ comment, parsed }) } return results }