Skip to content

Commit

Permalink
Merge pull request #2822 from lit26/features/enhanced-comments
Browse files Browse the repository at this point in the history
feat(introspectComments): Enhanced comment parsing
  • Loading branch information
kamilmysliwiec authored Feb 7, 2024
2 parents 500a718 + 7e6b64f commit 979f256
Show file tree
Hide file tree
Showing 6 changed files with 117 additions and 94 deletions.
168 changes: 82 additions & 86 deletions lib/plugin/utils/ast-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,27 @@ import {
UnionTypeNode
} from 'typescript';
import { isDynamicallyAdded } from './plugin-utils';
import {
DocNode,
DocExcerpt,
TSDocParser,
ParserContext,
DocComment,
DocBlock
} from '@microsoft/tsdoc';

export function renderDocNode(docNode: DocNode) {
let result: string = '';
if (docNode) {
if (docNode instanceof DocExcerpt) {
result += docNode.content.toString();
}
for (const childNode of docNode.getChildNodes()) {
result += renderDocNode(childNode);
}
}
return result;
}

export function isArray(type: Type) {
const symbol = type.getSymbol();
Expand Down Expand Up @@ -121,114 +142,89 @@ export function getMainCommentOfNode(
node: Node,
sourceFile: SourceFile
): string {
const sourceText = sourceFile.getFullText();
// in case we decide to include "// comments"
const replaceRegex =
/^\s*\** *@.*$|^\s*\/\*+ *|^\s*\/\/+.*|^\s*\/+ *|^\s*\*+ *| +$| *\**\/ *$/gim;
//const replaceRegex = /^ *\** *@.*$|^ *\/\*+ *|^ *\/+ *|^ *\*+ *| +$| *\**\/ *$/gim;

const commentResult = [];
const introspectComments = (comments?: CommentRange[]) =>
comments?.forEach((comment) => {
const commentSource = sourceText.substring(comment.pos, comment.end);
const oneComment = commentSource.replace(replaceRegex, '').trim();
if (oneComment) {
commentResult.push(oneComment);
}
});

const leadingCommentRanges = getLeadingCommentRanges(
sourceText,
node.getFullStart()
const tsdocParser: TSDocParser = new TSDocParser();
const parserContext: ParserContext = tsdocParser.parseString(
node.getFullText()
);
introspectComments(leadingCommentRanges);
if (!commentResult.length) {
const trailingCommentRanges = getTrailingCommentRanges(
sourceText,
node.getFullStart()
);
introspectComments(trailingCommentRanges);
const docComment: DocComment = parserContext.docComment;
return renderDocNode(docComment.summarySection).trim();
}

export function parseCommentDocValue(docValue: string, type: ts.Type) {
let value = docValue.replace(/'/g, '"').trim();

if (!type || !isString(type)) {
try {
value = JSON.parse(value);
} catch {}
} else if (isString(type)) {
if (value.split(' ').length !== 1 && !value.startsWith('"')) {
value = null;
} else {
value = value.replace(/"/g, '');
}
}
return commentResult.join('\n');
return value;
}

export function getTsDocTagsOfNode(
node: Node,
sourceFile: SourceFile,
typeChecker: TypeChecker
) {
const sourceText = sourceFile.getFullText();
export function getTsDocTagsOfNode(node: Node, typeChecker: TypeChecker) {
const tsdocParser: TSDocParser = new TSDocParser();
const parserContext: ParserContext = tsdocParser.parseString(
node.getFullText()
);
const docComment: DocComment = parserContext.docComment;

const tagDefinitions: {
[key: string]: {
regex: RegExp;
hasProperties: boolean;
repeatable: boolean;
};
} = {
example: {
regex:
/@example *((['"](?<string>.+?)['"])|(?<booleanOrNumber>[^ ]+?)|(?<array>(\[.+?\]))) *$/gim,
hasProperties: true,
repeatable: true
},
deprecated: {
regex: /@deprecated */gim,
hasProperties: false,
repeatable: false
}
};

const tagResults: any = {};
const introspectTsDocTags = (comments?: CommentRange[]) =>
comments?.forEach((comment) => {
const commentSource = sourceText.substring(comment.pos, comment.end);

for (const tag in tagDefinitions) {
const { regex, hasProperties, repeatable } = tagDefinitions[tag];

let value: any;

let execResult: RegExpExecArray;
while (
(execResult = regex.exec(commentSource)) &&
(!hasProperties || execResult.length > 1)
) {
if (repeatable && !tagResults[tag]) tagResults[tag] = [];

if (hasProperties) {
const docValue =
execResult.groups?.string ??
execResult.groups?.booleanOrNumber ??
(execResult.groups?.array &&
execResult.groups.array.replace(/'/g, '"'));

const type = typeChecker.getTypeAtLocation(node);

value = docValue;
if (!type || !isString(type)) {
try {
value = JSON.parse(value);
} catch {}
}
} else {
value = true;
}

if (repeatable) {
tagResults[tag].push(value);
} else {
tagResults[tag] = value;
const introspectTsDocTags = (docComment: DocComment) => {
for (const tag in tagDefinitions) {
const { hasProperties, repeatable } = tagDefinitions[tag];
const blocks = docComment.customBlocks.filter(
(block) => block.blockTag.tagName === `@${tag}`
);
if (blocks.length === 0) continue;
if (repeatable && !tagResults[tag]) tagResults[tag] = [];
const type = typeChecker.getTypeAtLocation(node);
if (hasProperties) {
blocks.forEach((block) => {
const docValue = renderDocNode(block.content).split('\n')[0];
const value = parseCommentDocValue(docValue, type);

if (value !== null) {
if (repeatable) {
tagResults[tag].push(value);
} else {
tagResults[tag] = value;
}
}
}
});
} else {
tagResults[tag] = true;
}
});
}
if (docComment.remarksBlock) {
tagResults['remarks'] = renderDocNode(
docComment.remarksBlock.content
).trim();
}
if (docComment.deprecatedBlock) {
tagResults['deprecated'] = true;
}
};
introspectTsDocTags(docComment);

const leadingCommentRanges = getLeadingCommentRanges(
sourceText,
node.getFullStart()
);
introspectTsDocTags(leadingCommentRanges);
return tagResults;
}

Expand Down
14 changes: 13 additions & 1 deletion lib/plugin/visitors/controller-class.visitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ export class ControllerClassVisitor extends AbstractFileVisitor {
if (!extractedComments) {
return [];
}
const tags = getTsDocTagsOfNode(node, sourceFile, typeChecker);
const tags = getTsDocTagsOfNode(node, typeChecker);

const properties = [
factory.createPropertyAssignment(
Expand All @@ -233,6 +233,18 @@ export class ControllerClassVisitor extends AbstractFileVisitor {
...(apiOperationExistingProps ?? factory.createNodeArray())
];

const hasRemarksKey = hasPropertyKey(
'description',
factory.createNodeArray(apiOperationExistingProps)
);
if (!hasRemarksKey && tags.remarks) {
const remarksPropertyAssignment = factory.createPropertyAssignment(
'description',
createLiteralFromAnyValue(factory, tags.remarks)
);
properties.push(remarksPropertyAssignment);
}

This comment has been minimized.

Copy link
@calebpitan

calebpitan Apr 8, 2024

Isn't it just better to leave the top-level comment as description and then "@remarks" or "@summary" as the operation summary?

const hasDeprecatedKey = hasPropertyKey(
'deprecated',
factory.createNodeArray(apiOperationExistingProps)
Expand Down
11 changes: 5 additions & 6 deletions lib/plugin/visitors/model-class.visitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -695,7 +695,8 @@ export class ModelClassVisitor extends AbstractFileVisitor {
return result;
}

const clonedMinLength = this.clonePrimitiveLiteral(factory, minLength) ?? minLength;
const clonedMinLength =
this.clonePrimitiveLiteral(factory, minLength) ?? minLength;
if (clonedMinLength) {
result.push(
factory.createPropertyAssignment('minLength', clonedMinLength)
Expand All @@ -707,10 +708,8 @@ export class ModelClassVisitor extends AbstractFileVisitor {
if (!canReferenceNode(maxLength, options)) {
return result;
}
const clonedMaxLength = this.clonePrimitiveLiteral(
factory,
maxLength
) ?? maxLength;
const clonedMaxLength =
this.clonePrimitiveLiteral(factory, maxLength) ?? maxLength;
if (clonedMaxLength) {
result.push(
factory.createPropertyAssignment('maxLength', clonedMaxLength)
Expand Down Expand Up @@ -822,7 +821,7 @@ export class ModelClassVisitor extends AbstractFileVisitor {
}
const propertyAssignments = [];
const comments = getMainCommentOfNode(node, sourceFile);
const tags = getTsDocTagsOfNode(node, sourceFile, typeChecker);
const tags = getTsDocTagsOfNode(node, typeChecker);

const keyOfComment = options.dtoKeyOfComment;
if (!hasPropertyKey(keyOfComment, existingProperties) && comments) {
Expand Down
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"start:debug": "nest start --watch --debug"
},
"dependencies": {
"@microsoft/tsdoc": "^0.14.2",
"@nestjs/mapped-types": "2.0.4",
"js-yaml": "4.1.0",
"lodash": "4.17.21",
Expand Down
6 changes: 5 additions & 1 deletion test/plugin/fixtures/app.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export class AppController {
/**
* create a Cat
*
* @remarks Creating a test cat
*
* @returns {Promise<Cat>}
* @memberof AppController
Expand Down Expand Up @@ -71,6 +73,8 @@ let AppController = exports.AppController = class AppController {
/**
* create a Cat
*
* @remarks Creating a test cat
*
* @returns {Promise<Cat>}
* @memberof AppController
*/
Expand Down Expand Up @@ -104,7 +108,7 @@ let AppController = exports.AppController = class AppController {
async findAll() { }
};
__decorate([
openapi.ApiOperation({ summary: \"create a Cat\" }),
openapi.ApiOperation({ summary: \"create a Cat\", description: \"Creating a test cat\" }),
(0, common_1.Post)(),
openapi.ApiResponse({ status: 201, type: Cat })
], AppController.prototype, \"create\", null);
Expand Down

0 comments on commit 979f256

Please sign in to comment.