1/* 2 * Copyright (c) 2023 Huawei Device Co., Ltd. 3 * Licensed under the Apache License, Version 2.0 (the "License"); 4 * you may not use this file except in compliance with the License. 5 * You may obtain a copy of the License at 6 * 7 * http://www.apache.org/licenses/LICENSE-2.0 8 * 9 * Unless required by applicable law or agreed to in writing, software 10 * distributed under the License is distributed on an "AS IS" BASIS, 11 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 * See the License for the specific language governing permissions and 13 * limitations under the License. 14 */ 15 16import path from 'path'; 17import excelJs from 'exceljs'; 18import fs from 'fs'; 19import { Command } from 'commander'; 20import { ConstantValue, Instruct, StringResourceId } from '../utils/constant'; 21import { FileUtils } from '../utils/fileUtils'; 22import { LogUtil } from '../utils/logUtil'; 23import { StringResource, StringUtils } from '../utils/stringUtils'; 24import type { 25 Context, LogReporter, CheckLogResult, ModifyLogResult, LogWriter, JSDocModifyType, 26 JSDocCheckErrorType, CommentData 27} from './typedef'; 28import { comment, Options, sourceParser, rawInfo } from './typedef'; 29import ts from 'typescript'; 30import type { CommentRange, TransformationContext, TransformationResult } from 'typescript'; 31 32export class ContextImpl implements Context { 33 options: Options; 34 inputFile: string; 35 outputFile: string; 36 rawSourceCodeInfo?: rawInfo.RawSourceCodeInfo; 37 logReporter?: LogReporter; 38 39 constructor(inputFile: string, outputFile: string, options?: Options) { 40 this.inputFile = inputFile; 41 this.outputFile = outputFile; 42 this.options = options ? options : new Options(); 43 } 44 45 setLogReporter(logReporter: LogReporter): void { 46 this.logReporter = logReporter; 47 } 48 49 getOptions(): Options { 50 return this.options; 51 } 52 53 setRawSourceInfo(rawInfo: rawInfo.RawSourceCodeInfo): void { 54 this.rawSourceCodeInfo = rawInfo; 55 } 56 57 getRawSourceCodeInfo(): rawInfo.RawSourceCodeInfo { 58 return this.rawSourceCodeInfo || new RawSourceCodeInfoImpl(''); 59 } 60 61 getLogReporter(): LogReporter { 62 if (this.logReporter === undefined) { 63 this.logReporter = new LogReporterImpl(); 64 let writer: LogWriter; 65 if (this.getOptions().isTest) { 66 writer = new logWriter.JsonWriter(); 67 } else { 68 writer = new logWriter.ExcelWriter(this.logReporter); 69 } 70 this.logReporter.setWriter(writer); 71 } 72 return this.logReporter; 73 } 74 75 getOutputFile(): string { 76 return this.outputFile; 77 } 78 79 getInputFile(): string { 80 return this.inputFile; 81 } 82 83 getSourceParser(code: string): sourceParser.SourceCodeParser { 84 return new SourceCodeParserImpl(code, this.options); 85 } 86} 87 88 89export class OutputFileHelper { 90 91 static getOutputFilePath(inputParam: InputParameter, sourceFile: string): string { 92 return inputParam.isHandleMultiFiles() ? OutputFileHelper.getMultiOutputFilePath(inputParam, sourceFile) : 93 OutputFileHelper.getSingleOutputFilePath(inputParam); 94 } 95 96 static getMultiOutputFilePath(inputParam: InputParameter, sourceFile: string): string { 97 // 计算原文件与输入目录的相对路径 98 const relativePath = path.relative(inputParam.inputFilePath!, sourceFile); 99 // 如果未设置 outputPath, 同级目录创建新目录 100 return path.resolve(this.getMultiOutputDir(inputParam), relativePath); 101 } 102 103 static getMultiOutputDir(inputParam: InputParameter): string { 104 if (inputParam.outputFilePath) { 105 return inputParam.outputFilePath; 106 } 107 const fileName = path.basename(inputParam.inputFilePath!); 108 const dirname = path.dirname(inputParam.inputFilePath!); 109 return path.resolve(dirname, `${fileName}_new`); 110 } 111 112 static getSingleOutputFilePath(inputParam: InputParameter): string { 113 if (inputParam.outputFilePath) { 114 return inputParam.outputFilePath; 115 } 116 // 同级目录创建新文件 117 const fileName = path.basename(inputParam.inputFilePath!, ConstantValue.DTS_EXTENSION); 118 return path.join(inputParam.inputFilePath!, '..', `${fileName}_new${ConstantValue.DTS_EXTENSION}`); 119 } 120 121 // 获取报告输出路径 122 static getLogReportFilePath(inputParam: InputParameter): string { 123 const fileName = path.basename(inputParam.inputFilePath, '.d.ts'); 124 if (inputParam.outputFilePath) { 125 const dirName = path.dirname(inputParam.outputFilePath); 126 return path.join(dirName, `${fileName}`); 127 } else { 128 const dirName = path.dirname(inputParam.inputFilePath); 129 return path.join(dirName, `${fileName}`); 130 } 131 } 132} 133 134function getCommentNode(node: ts.Node, parentNode: comment.CommentNode | undefined, sourceFile: ts.SourceFile): comment.CommentNode { 135 const leadingComments: comment.CommentInfo[] = CommentHelper.getNodeLeadingComments(node, sourceFile); 136 const currentCommentNode: comment.CommentNode = { 137 astNode: node, 138 parentNode: parentNode, 139 commentInfos: [] 140 }; 141 currentCommentNode.commentInfos?.push(...leadingComments); 142 return currentCommentNode; 143} 144 145export class SourceCodeParserImpl extends sourceParser.SourceCodeParser { 146 options: Options; 147 148 /** 149 * 可能存在注释的节点类型。 150 */ 151 commentNodeWhiteList: ts.SyntaxKind[] = [ 152 ts.SyntaxKind.VariableDeclaration, ts.SyntaxKind.VariableDeclarationList, ts.SyntaxKind.FunctionDeclaration, 153 ts.SyntaxKind.ClassDeclaration, ts.SyntaxKind.InterfaceDeclaration, ts.SyntaxKind.TypeAliasDeclaration, 154 ts.SyntaxKind.EnumDeclaration, ts.SyntaxKind.ModuleDeclaration, ts.SyntaxKind.NamespaceExportDeclaration, 155 ts.SyntaxKind.PropertySignature, ts.SyntaxKind.CallSignature, ts.SyntaxKind.MethodSignature, ts.SyntaxKind.MethodDeclaration, 156 ts.SyntaxKind.EnumMember, ts.SyntaxKind.VariableStatement, ts.SyntaxKind.PropertyDeclaration, ts.SyntaxKind.Constructor, 157 ts.SyntaxKind.TypeLiteral, ts.SyntaxKind.ImportDeclaration, ts.SyntaxKind.LabeledStatement 158 ]; 159 visitNodeChildrenWhilteList: ts.SyntaxKind[] = [ 160 ts.SyntaxKind.ModuleDeclaration, ts.SyntaxKind.ModuleBlock, ts.SyntaxKind.NamespaceExportDeclaration, ts.SyntaxKind.ClassDeclaration, 161 ts.SyntaxKind.InterfaceDeclaration, ts.SyntaxKind.EnumDeclaration, ts.SyntaxKind.MethodDeclaration, ts.SyntaxKind.Parameter, 162 ts.SyntaxKind.TypeLiteral, ts.SyntaxKind.PropertySignature, ts.SyntaxKind.TypeAliasDeclaration 163 ]; 164 165 constructor(code: string, options: Options) { 166 super(code); 167 this.options = options; 168 } 169 170 private shouldNotify(node: ts.Node): boolean { 171 // if kind = ImportDeclaration, comments may be license declarations 172 return this.commentNodeWhiteList.includes(node.kind); 173 } 174 175 private shouldForEachChildren(node: ts.Node): boolean { 176 return true; 177 } 178 179 createSourceFile(content: string, name?: string | undefined): ts.SourceFile | undefined { 180 const sourceFile = ts.createSourceFile(name ? name : 'memory', content, this.options.scriptTarget, true); 181 // 没有解析成AST树,非代码文件 182 if (sourceFile.statements.length === 0) { 183 return undefined; 184 } 185 return sourceFile; 186 } 187 188 transform(callback: sourceParser.ITransformCallback): ts.SourceFile | undefined { 189 let transformSourceFile = this.createSourceFile(this.content, 'transform'); 190 if (!transformSourceFile) { 191 return undefined; 192 } 193 function transformCallback(context: TransformationContext) { 194 return (rootNode: ts.Node) => { 195 function visitor(node: ts.Node): ts.Node { 196 const commentNode = getCommentNode(node, undefined, transformSourceFile!); 197 const newNode: ts.Node | undefined = callback.onTransformNode(commentNode); 198 return ts.visitEachChild(newNode ? newNode : node, visitor, context); 199 } 200 return ts.visitEachChild(rootNode, visitor, context); 201 }; 202 }; 203 const rootNode = callback.onTransformNode({ astNode: transformSourceFile }); 204 if (rootNode && ts.isSourceFile(rootNode) && rootNode !== transformSourceFile) { 205 transformSourceFile = rootNode; 206 } 207 const transformResult: TransformationResult<ts.Node> = ts.transform(transformSourceFile, [transformCallback]); 208 if (transformResult.transformed.length > 0) { 209 return transformResult.transformed[0] as ts.SourceFile; 210 } 211 return transformSourceFile; 212 } 213 214 visitEachNodeComment(callback: sourceParser.INodeVisitorCallback, onlyVisitHasComment?: boolean): ts.SourceFile | undefined { 215 const forEachSourceFile = this.createSourceFile(this.content, 'forEach'); 216 if (!forEachSourceFile) { 217 return undefined; 218 } 219 const thiz = this; 220 const handledComments: Set<string> = new Set(); 221 function nodeVisitor(node: ts.Node, parentNode: comment.CommentNode | undefined, sourceFile: ts.SourceFile): void { 222 const currentCommentNode = getCommentNode(node, parentNode, sourceFile); 223 const NOTE_LENGTH = 0; 224 thiz.skipHandledComments(handledComments, currentCommentNode); 225 const hasComment: boolean = currentCommentNode.commentInfos ? 226 currentCommentNode.commentInfos.length > NOTE_LENGTH : false; 227 const { line, character } = node.getSourceFile().getLineAndCharacterOfPosition(node.getStart()); 228 if (thiz.shouldNotifyCallback(node, hasComment, onlyVisitHasComment)) { 229 LogUtil.d('SourceCodeParserImpl', `kind: ${node.kind}, line: ${line + 1}, ${JSON.stringify(currentCommentNode.commentInfos)}`); 230 callback.onVisitNode(currentCommentNode); 231 } else { 232 LogUtil.d('SourceCodeParserImpl', 233 `skip, [ ${node.getText()} ] kind: ${node.kind}, line: ${line + 1}, character: ${character}, comment size: ${currentCommentNode.commentInfos?.length}`); 234 } 235 if (thiz.shouldForEachChildren(node)) { 236 node.forEachChild((child) => { 237 nodeVisitor(child, currentCommentNode, sourceFile); 238 }); 239 } 240 } 241 forEachSourceFile.forEachChild((child) => { 242 nodeVisitor(child, undefined, forEachSourceFile); 243 }); 244 return forEachSourceFile; 245 } 246 247 private skipHandledComments(handledComments: Set<string>, commentNode: comment.CommentNode): void { 248 const unHandledComments: Array<comment.CommentInfo> = []; 249 commentNode.commentInfos?.forEach((info) => { 250 const key = `${info.pos}:${info.end}`; 251 if (info.isInstruct || !handledComments.has(key)) { 252 unHandledComments.push(info); 253 handledComments.add(key); 254 } 255 }); 256 if (unHandledComments.length !== commentNode.commentInfos?.length) { 257 commentNode.commentInfos = unHandledComments; 258 } 259 } 260 261 private shouldNotifyCallback(node: ts.Node, hasComment: boolean, onlyVisitHasComment?: boolean): boolean { 262 if (!this.shouldNotify(node)) { 263 return false; 264 } 265 return !(onlyVisitHasComment && onlyVisitHasComment === true && !hasComment); 266 } 267 268 printSourceFile(sourceFile: ts.SourceFile): string { 269 const printer = ts.createPrinter(this.options.printerOptions); 270 let codeString = printer.printFile(sourceFile); 271 // replace empty line instruct 272 codeString = codeString.replaceAll(/\s*\/\/Instruct_new_line/g, '\n'); 273 return codeString; 274 } 275} 276 277 278export class CommentHelper { 279 static LICENSE_KEYWORD = 'Copyright'; 280 static REFERENCE_REGEXP = /\/\/\/\s*<reference\s*path/g; 281 static REFERENCE_COMMENT_REGEXP = /\/\s*<reference\s*path/g; 282 static MULTI_COMMENT_DELIMITER = '/**'; 283 284 /** 285 * 给节点追加注释(注意:无法在原始注释中追加,只能将原始注释完全复制一份并修改再做完整替换) 286 * 287 * @param node ast 节点对象 288 * @param commentInfos 注释列表 289 */ 290 static addComment(node: ts.Node, commentInfos: Array<comment.CommentInfo>): void { 291 if (commentInfos.length === 0) { 292 return; 293 } 294 CommentHelper.ignoreOriginalComment(node); 295 commentInfos.forEach((info) => { 296 // might be a license 297 if (info.ignore) { 298 return; 299 } 300 const assembledComment = CommentHelper.assembleComment(info); 301 const kind = info.isMultiLine ? ts.SyntaxKind.MultiLineCommentTrivia : ts.SyntaxKind.SingleLineCommentTrivia; 302 if (info.isLeading) { 303 ts.addSyntheticLeadingComment(node, kind, assembledComment, true); 304 } else { 305 ts.addSyntheticTrailingComment(node, kind, assembledComment, true); 306 } 307 }); 308 } 309 310 /** 311 * 封装符合格式的注释 312 * 313 * @param commentInfo 314 * @returns 315 */ 316 static assembleComment(commentInfo: comment.CommentInfo): string { 317 const writer = new CommentWriter(commentInfo.isMultiLine); 318 return writer.publish(commentInfo); 319 } 320 321 322 /** 323 * 设置节点注释,覆盖已有的注释。(注意:原始文件的注释不会被替换,只在输出时忽略原始注释,打印新的注释) 324 * 325 * @param node 326 * @param commentInfos 327 * @param commentKind 328 * @returns 329 */ 330 static setComment(node: ts.Node, commentInfos: Array<comment.CommentInfo>, commentKind?: number): void { 331 if (commentInfos.length === 0) { 332 return; 333 } 334 CommentHelper.ignoreOriginalComment(node); 335 const syntheticLeadingComments: Array<ts.SynthesizedComment> = new Array(); 336 commentInfos.forEach((info) => { 337 // might be a license 338 if (info.ignore) { 339 return; 340 } 341 syntheticLeadingComments.push({ 342 text: CommentHelper.assembleComment(info), 343 pos: -1, 344 end: -1, 345 hasLeadingNewline: true, 346 hasTrailingNewLine: true, 347 kind: info.isMultiLine ? ts.SyntaxKind.MultiLineCommentTrivia : ts.SyntaxKind.SingleLineCommentTrivia 348 }); 349 }); 350 351 if (!commentKind || commentKind === comment.CommentLocationKind.LEADING) { 352 ts.setSyntheticLeadingComments(node, syntheticLeadingComments); 353 } else { 354 ts.setSyntheticTrailingComments(node, syntheticLeadingComments); 355 } 356 } 357 358 /** 359 * 打印源码时忽略节点的原始注释 360 * 361 * @param node 362 */ 363 private static ignoreOriginalComment(node: ts.Node): void { 364 // ignore the original comment 365 ts.setEmitFlags(node, ts.EmitFlags.NoLeadingComments); 366 } 367 368 /** 369 * 将多段注释文本解析成注释对象。 370 * 371 * @param comment 372 * @returns 373 */ 374 static parseComment(comment: string, commentKind: ts.CommentKind, isLeading: boolean): comment.CommentInfo { 375 const { parse } = require('comment-parser'); 376 const commentInfo: comment.CommentInfo = { 377 text: comment, 378 isMultiLine: commentKind === ts.SyntaxKind.MultiLineCommentTrivia, 379 isLeading: isLeading, 380 description: '', 381 commentTags: [], 382 parsedComment: undefined, 383 pos: -1, 384 end: -1, 385 ignore: false, 386 isApiComment: false, 387 isInstruct: false 388 }; 389 let commentString = comment; 390 let parsedComments = parse(commentString); 391 const START_POSITION_INDEX = 2; 392 // 无法被解析的注释,可能以 /* 开头或是单行注释 393 if (parsedComments.length === 0) { 394 // 注释是 /// <reference path="" /> 或 单行注释 395 if (StringUtils.hasSubstring(commentString, this.REFERENCE_REGEXP) || 396 commentKind === ts.SyntaxKind.SingleLineCommentTrivia) { 397 commentInfo.isMultiLine = false; 398 // 注释内容需丢弃 "//" 399 commentInfo.text = commentString.substring(START_POSITION_INDEX, commentString.length); 400 } 401 return commentInfo; 402 } 403 commentInfo.parsedComment = parsedComments[0]; 404 commentInfo.description = parsedComments[0].description; 405 parsedComments[0].tags.forEach((tagObject: CommentData) => { 406 commentInfo.commentTags.push({ 407 tag: tagObject.tag, 408 name: tagObject.name, 409 type: tagObject.type, 410 optional: tagObject.optional, 411 description: tagObject.description, 412 source: tagObject.source[0].source, 413 lineNumber: tagObject.source[0].number, 414 tokenSource: tagObject.source, 415 defaultValue: tagObject.default ? tagObject.default : undefined 416 }); 417 }); 418 commentInfo.isApiComment = true; 419 return commentInfo; 420 } 421 422 /** 423 * 获取指定AST节点上的注释,若无注释返回空数组。 424 * 425 * @param node 426 * @param sourceFile 427 * @returns 428 */ 429 static getNodeLeadingComments(node: ts.Node, sourceFile: ts.SourceFile): comment.CommentInfo[] { 430 try { 431 const leadingCommentRange: CommentRange[] | undefined = ts.getLeadingCommentRanges(sourceFile.getFullText(), node.getFullStart()); 432 if (leadingCommentRange?.length) { 433 const parsedCommentInfos: Array<comment.CommentInfo> = []; 434 leadingCommentRange.forEach((range) => { 435 const comment = sourceFile.getFullText().slice(range.pos, range.end); 436 const commentInfo = CommentHelper.parseComment(comment, range.kind, true); 437 commentInfo.pos = range.pos; 438 commentInfo.end = range.end; 439 parsedCommentInfos.push(commentInfo); 440 this.fixReferenceComment(commentInfo, parsedCommentInfos); 441 }); 442 this.fixLicenseComment(node, parsedCommentInfos); 443 return parsedCommentInfos; 444 } 445 return []; 446 } catch (error) { 447 LogUtil.d('CommentHelper', `node(kind=${node.kind}) is created in memory.`); 448 return []; 449 } 450 } 451 452 private static fixReferenceComment(commentInfo: comment.CommentInfo, parsedCommentInfos: Array<comment.CommentInfo>): void { 453 if (commentInfo.isMultiLine || commentInfo.isApiComment || 454 !StringUtils.hasSubstring(commentInfo.text, this.REFERENCE_COMMENT_REGEXP)) { 455 return; 456 } 457 parsedCommentInfos.push(this.getEmptyLineComment()); 458 } 459 460 /** 461 * 如果license注释与头注释间没有空白行会导致license丢失. 462 * 463 * @param commentInfos 464 */ 465 private static fixLicenseComment(node: ts.Node, commentInfos: comment.CommentInfo[]): void { 466 if (commentInfos.length === 0) { 467 return; 468 } 469 // 如果只有一个注释并且是license, license 与 node 间需要一个空行 470 if (commentInfos.length === 1) { 471 if (StringUtils.hasSubstring(commentInfos[0].text, this.LICENSE_KEYWORD)) { 472 const copyRightPosition = node.getSourceFile().getLineAndCharacterOfPosition(commentInfos[0].end); 473 const nodePosition = node.getSourceFile().getLineAndCharacterOfPosition(node.getStart()); 474 if ((nodePosition.line - copyRightPosition.line) === 1) { 475 commentInfos.push(this.getEmptyLineComment()); 476 } else { 477 commentInfos[0].ignore = true; 478 } 479 } 480 return; 481 } 482 const firstCommentInfo: comment.CommentInfo = commentInfos[0]; 483 if (!StringUtils.hasSubstring(firstCommentInfo.text, this.LICENSE_KEYWORD)) { 484 return; 485 } 486 firstCommentInfo.isApiComment = false; 487 // license 注释在第一位 488 const firstPosition = node.getSourceFile().getLineAndCharacterOfPosition(firstCommentInfo.end); 489 const secondPosition = node.getSourceFile().getLineAndCharacterOfPosition(commentInfos[1].pos); 490 // 无空格 491 if ((secondPosition.line - firstPosition.line) === 1) { 492 firstCommentInfo.ignore = false; 493 } else { 494 firstCommentInfo.ignore = true; 495 } 496 // license注释与头注释之间没有import语句会导致两者间的空行丢失, 插入空行指令 497 commentInfos.splice(1, 0, this.getEmptyLineComment()); 498 } 499 500 static getEmptyLineComment(): comment.CommentInfo { 501 return { 502 text: Instruct.EMPTY_LINE, 503 isMultiLine: false, 504 isLeading: true, 505 description: '', 506 commentTags: [], 507 parsedComment: undefined, 508 pos: -1, 509 end: -1, 510 ignore: false, 511 isApiComment: false, 512 isInstruct: true 513 }; 514 } 515 516 static getNodeTrailingComments(node: ts.Node, sourceFile: ts.SourceFile): comment.CommentInfo[] { 517 try { 518 const trailingCommentRange: CommentRange[] | undefined = ts.getTrailingCommentRanges(sourceFile.getFullText(), node.getEnd() + 1); 519 if (trailingCommentRange?.length) { 520 const commentNodes = trailingCommentRange.map((range) => { 521 const comment = sourceFile.getFullText().slice(range.pos, range.end); 522 return CommentHelper.parseComment(comment.replace(/^\s*\/\//g, ''), range.kind, false); 523 }); 524 return commentNodes; 525 } 526 return []; 527 } catch (error) { 528 LogUtil.w('CommentHelper', 'getNodeTrailingComments:' + error); 529 return []; 530 } 531 } 532} 533 534/** 535 * 注释封装类 536 */ 537class CommentWriter { 538 isMultiLine: boolean; 539 commentBlockDelimiter = '/**'; 540 541 constructor(isMultiLine: boolean) { 542 this.isMultiLine = isMultiLine; 543 } 544 545 /** 546 * 构建完整的注释文本段 547 */ 548 publish(commentInfo: comment.CommentInfo): string { 549 const parsedComment: comment.ParsedComment | undefined = commentInfo.parsedComment; 550 // 如果没有解析过的注释对象(可能是license),使用原始注释内容 551 let plainComment = parsedComment ? this.restoreParsedComment(parsedComment, commentInfo.commentTags) : commentInfo.text; 552 const START_POSITION_INDEX = 2; 553 if (commentInfo.isMultiLine) { 554 // 删除起始 /* 和末尾 */ 符号 555 plainComment = plainComment.substring(START_POSITION_INDEX, plainComment.length - START_POSITION_INDEX); 556 } 557 return plainComment; 558 } 559 560 private restoreParsedComment(parsedComment: comment.ParsedComment, commentTags: Array<comment.CommentTag>): string { 561 const newSourceArray: Array<comment.CommentSource> = []; 562 const { stringify } = require('comment-parser'); 563 if (parsedComment.source.length === 1) { 564 return stringify(parsedComment); 565 } 566 for (let source of parsedComment.source) { 567 // 保留描述信息,去除标签注释 568 if (source.tokens.tag !== '') { 569 break; 570 } 571 // 描述信息内有空行 572 if (source.tokens.description === '') { 573 continue; 574 } 575 // 描述信息写在/** 之后 576 if (source.tokens.delimiter === this.commentBlockDelimiter) { 577 source.tokens.delimiter = '*'; 578 source.tokens.postDelimiter = ' '; 579 } 580 // 除了起始符号为空,其他场景则重置为一个空格 581 // ts api 提供的写注释接口缩进是参考当前节点的,因此一个空格可以保证格式正确 582 if (source.tokens.start !== '') { 583 source.tokens.start = ' '; 584 } 585 newSourceArray.push(source); 586 } 587 // 添加注释首行 588 newSourceArray.splice(0, 0, { number: 0, source: '', tokens: this.getCommentStartToken() }); 589 const lastSource = newSourceArray[newSourceArray.length - 1]; 590 if (commentTags.length !== 0) { 591 if (!this.isEndLine(lastSource.tokens)) { 592 this.addNewEmptyLineIfNeeded(newSourceArray, lastSource); 593 this.addTags(newSourceArray, commentTags); 594 } 595 } else { 596 newSourceArray.push({ number: 0, source: '', tokens: this.getCommentEndToken() }); 597 } 598 parsedComment.source = newSourceArray; 599 return stringify(parsedComment); 600 } 601 602 private addNewEmptyLineIfNeeded(newSourceArray: Array<comment.CommentSource>, lastSource: comment.CommentSource): void { 603 if (this.shouldInsertNewEmptyLine(lastSource.tokens)) { 604 // 注释还原成字符串时, number, source 属性不会被用到 605 newSourceArray.push({ number: 0, source: '', tokens: this.getNewLineToken() }); 606 } 607 } 608 609 private addTags(newSourceArray: Array<comment.CommentSource>, commentTags: Array<comment.CommentTag>): void { 610 commentTags.forEach((commentTag) => { 611 // tag的描述为多行,复用原始的描述信息 612 const tokenSourceLen = commentTag.tokenSource.length; 613 if (tokenSourceLen > 1) { 614 // 数组第一个为包含tag的注释 615 const tagSameLineDescription = commentTag.tokenSource[0].tokens.description; 616 newSourceArray.push({ number: 0, source: '', tokens: this.getCommentToken(commentTag, tagSameLineDescription) }); 617 // 将tag剩余的描述信息放入 618 for (let index = 1; index < tokenSourceLen; index++) { 619 const tagDescriptionSource = commentTag.tokenSource[index]; 620 // 末尾的tag会包含注释结束符, tag 间可能有空行 621 if (tagDescriptionSource.tokens.end !== '*/' && tagDescriptionSource.tokens.description !== '') { 622 tagDescriptionSource.tokens.start = ' '; 623 newSourceArray.push(tagDescriptionSource); 624 } 625 } 626 } else { 627 // tag注释只有一行 628 newSourceArray.push({ number: 0, source: '', tokens: this.getCommentToken(commentTag, commentTag.description) }); 629 } 630 }); 631 newSourceArray.push({ number: 0, source: '', tokens: this.getEndLineToken() }); 632 } 633 634 private getCommentToken(commentTag: comment.CommentTag, description: string): comment.CommentToken { 635 const tokens: comment.CommentToken = { 636 // 起始空格 637 start: ' ', 638 // * 定界符 639 delimiter: '*', 640 // 定界符之后空格 641 postDelimiter: ' ', 642 // 标签 643 tag: `@${commentTag.tag}`, 644 // 标签后空格 645 postTag: ' ', 646 // 标签名称 647 name: commentTag.optional ? 648 (commentTag.defaultValue ? `[${commentTag.name} = ${commentTag.defaultValue}]` : `[${commentTag.name}]`) : commentTag.name, 649 // 标签名称后空格 650 postName: commentTag.name === '' ? '' : ' ', 651 // 类型 652 type: commentTag.type === '' ? '' : `{ ${commentTag.type} }`, 653 // 类型之后空格 654 postType: commentTag.type === '' ? '' : ' ', 655 // 描述 656 description: description, 657 end: '', 658 lineEnd: '' 659 }; 660 return tokens; 661 } 662 663 private getCommentEndToken(): comment.CommentToken { 664 return { 665 start: ' ', 666 delimiter: '', 667 postDelimiter: '', 668 tag: '', 669 postTag: '', 670 name: '', 671 postName: '', 672 type: '', 673 postType: '', 674 description: '', 675 end: '*、', 676 lineEnd: '' 677 }; 678 } 679 680 private getCommentStartToken(): comment.CommentToken { 681 return { 682 start: '', 683 delimiter: this.commentBlockDelimiter, 684 postDelimiter: '', 685 tag: '', 686 postTag: '', 687 name: '', 688 postName: '', 689 type: '', 690 postType: '', 691 description: '', 692 end: '', 693 lineEnd: '' 694 }; 695 } 696 697 private getNewLineToken(): comment.CommentToken { 698 return { 699 start: ' ', 700 delimiter: '*', 701 postDelimiter: '', 702 tag: '', 703 postTag: '', 704 name: '', 705 postName: '', 706 type: '', 707 postType: '', 708 description: '', 709 end: '', 710 lineEnd: '' 711 }; 712 } 713 714 private getEndLineToken(): comment.CommentToken { 715 return { 716 start: ' ', 717 delimiter: '', 718 postDelimiter: '', 719 tag: '', 720 postTag: '', 721 name: '', 722 postName: '', 723 type: '', 724 postType: '', 725 description: '', 726 end: '*/', 727 lineEnd: '' 728 }; 729 } 730 731 private shouldInsertNewEmptyLine(token: comment.CommentToken): boolean { 732 const lastDescriptionNotEmpty = token.tag === '' && token.delimiter === '*' && token.description !== ''; 733 const commentStartLine = token.tag === '' && token.delimiter === this.commentBlockDelimiter; 734 return lastDescriptionNotEmpty && !commentStartLine; 735 } 736 737 private isEndLine(token: comment.CommentToken): boolean { 738 return token.tag === '' && token.end === '*/'; 739 } 740} 741 742export class AstNodeHelper { 743 static noNeedSignatureNodeTypes: Array<number> = [ 744 ts.SyntaxKind.ModuleBlock, ts.SyntaxKind.Block, ts.SyntaxKind.CaseBlock 745 ]; 746 static skipSignatureNode: Array<number> = [ 747 ts.SyntaxKind.PropertySignature, ts.SyntaxKind.MethodSignature, ts.SyntaxKind.EnumMember, ts.SyntaxKind.Constructor, 748 ts.SyntaxKind.PropertyDeclaration, ts.SyntaxKind.MethodDeclaration, ts.SyntaxKind.TypeLiteral 749 ]; 750 751 /** 752 * 获取节点的签名信息。签名信息会追加父节点签名信息,确保全局唯一。 753 * 注释改动导致行数变化, AST节点的顺序不会影响签名。 754 * 755 * @param node Ast节点 756 * @returns string 节点签名信息 757 */ 758 static getNodeSignature(node: ts.Node): string { 759 if (!node || AstNodeHelper.noNeedSignatureNodeTypes.includes(node.kind)) { 760 return ''; 761 } 762 let nodeSignature = `${AstNodeHelper.getType(node)}#`; 763 node.forEachChild((child) => { 764 if (!AstNodeHelper.skipSignatureNode.includes(child.kind) && !AstNodeHelper.noNeedSignatureNodeTypes.includes(child.kind)) { 765 nodeSignature += `${AstNodeHelper.getChildPlainText(child)}`; 766 } 767 }); 768 const qualifiedSignature = this.getParentNodeSignature(node.parent) + nodeSignature; 769 LogUtil.d('AstNodeHelper', `qualifiedSignature = ${qualifiedSignature}`); 770 return qualifiedSignature; 771 } 772 773 private static getChildPlainText(node: ts.Node): string { 774 if (node.getChildCount() === 0) { 775 return node.getText(); 776 } 777 let content = ''; 778 node.forEachChild((child) => { 779 content += `${this.getChildPlainText(child)}`; 780 }); 781 return content; 782 } 783 784 private static getType(node: ts.Node): string { 785 return `${ts.SyntaxKind[node.kind]}`; 786 } 787 788 private static getParentNodeSignature(node: ts.Node): string { 789 if (!node || node.kind === ts.SyntaxKind.SourceFile) { 790 return ''; 791 } 792 const parentNodeSignature = this.getNodeSignature(node); 793 if (parentNodeSignature === '') { 794 return this.getNodeSignature(node.parent); 795 } 796 return parentNodeSignature; 797 } 798} 799 800/** 801 * 802 * 命令行入参对象 803 * 804 */ 805export class InputParameter { 806 inputFilePath: string = ''; 807 outputFilePath: string | undefined; 808 logLevel: string = ''; 809 splitUnionTypeApi: boolean = false; 810 branch: string = 'master'; 811 test: boolean = false; 812 options: Options = new Options(); 813 814 parse(): void { 815 const program = new Command(); 816 program 817 .name('jsdoc-tool') 818 .description('CLI to format d.ts') 819 .version('0.1.0') 820 .allowUnknownOption(true) 821 .requiredOption('-i, --input <path>', `${StringResource.getString(StringResourceId.COMMAND_INPUT_DESCRIPTION)}`) 822 .option('-o, --output <path>', `${StringResource.getString(StringResourceId.COMMAND_OUT_DESCRIPTION)}`) 823 .option('-l, --logLevel <INFO,WARN,DEBUG,ERR>', `${StringResource.getString(StringResourceId.COMMAND_LOGLEVEL_DESCRIPTION)}`, 'INFO') 824 .option('-s, --split', `${StringResource.getString(StringResourceId.COMMAND_SPLIT_API)}`, false) 825 .option('-b, --branch <string>', `${StringResource.getString(StringResourceId.COMMAND_BRANCH)}`, 'master') 826 .option('-t, --test', `${StringResource.getString(StringResourceId.COMMAND_TEST)}`, false); 827 828 program.parse(); 829 const options = program.opts(); 830 this.inputFilePath = options.input; 831 this.outputFilePath = options.output; 832 this.logLevel = options.logLevel; 833 this.splitUnionTypeApi = options.split; 834 this.branch = options.branch; 835 this.test = options.test; 836 this.checkInput(); 837 } 838 839 private checkInput(): void { 840 this.inputFilePath = path.resolve(this.inputFilePath); 841 this.outputFilePath = this.outputFilePath ? path.resolve(this.outputFilePath) : undefined; 842 843 if (this.outputFilePath && (this.outputFilePath === path.basename(this.outputFilePath))) { 844 throw StringResource.getString(StringResourceId.INVALID_PATH); 845 } 846 847 this.checkFileExists(this.inputFilePath); 848 849 if (FileUtils.isDirectory(this.inputFilePath)) { 850 if (this.outputFilePath) { 851 const extName = path.extname(this.outputFilePath); 852 // if input is a directory, output must be a directory 853 if (extName !== '') { 854 throw `-o, --output ${StringResource.getString(StringResourceId.OUTPUT_MUST_DIR)}`; 855 } 856 const relativePath = path.relative(this.inputFilePath, this.outputFilePath); 857 // the output directory cannot be a subdirectory of the input directory 858 if (relativePath === '' || !relativePath.startsWith('..')) { 859 throw `-o, --output ${StringResource.getString(StringResourceId.OUTPUT_SUBDIR_INPUT)}`; 860 } 861 } 862 } else { 863 if (!this.inputFilePath.endsWith(ConstantValue.DTS_EXTENSION)) { 864 throw StringResource.getString(StringResourceId.NOT_DTS_FILE); 865 } 866 // if input is a file, output file must be a file 867 if (this.outputFilePath) { 868 const extName = path.extname(this.outputFilePath); 869 if (extName === '') { 870 throw `-o, --output ${StringResource.getString(StringResourceId.OUTPUT_MUST_FILE)}`; 871 } 872 // output file is same with input file 873 if (this.outputFilePath === this.inputFilePath) { 874 throw `-o, --output ${StringResource.getString(StringResourceId.OUTPUT_SAME_WITH_INPUT)}`; 875 } 876 } 877 } 878 this.options.splitUnionTypeApi = this.splitUnionTypeApi; 879 this.options.workingBranch = this.branch; 880 this.options.isTest = this.test; 881 } 882 883 private checkFileExists(filePath: string): void { 884 if (!FileUtils.isExists(filePath)) { 885 throw `${StringResource.getString(StringResourceId.INPUT_FILE_NOT_FOUND)}: ${filePath}`; 886 } 887 } 888 889 isHandleMultiFiles(): boolean { 890 return FileUtils.isDirectory(this.inputFilePath); 891 } 892 893 getOptions(): Options { 894 return this.options; 895 } 896} 897 898export class RawSourceCodeInfoImpl extends rawInfo.RawSourceCodeInfo { 899 rawInfoMap: Map<string, rawInfo.RawNodeInfo> = new Map(); 900 901 addRawNodeInfo(ndoeSignature: string, node: ts.Node, line: number, character: number): void { 902 this.rawInfoMap.set(ndoeSignature, { 903 line: line, 904 character: character, 905 astNode: node 906 }); 907 } 908 909 findRawNodeInfo(node: ts.Node): rawInfo.RawNodeInfo | undefined { 910 const nodeSignature = AstNodeHelper.getNodeSignature(node); 911 return this.rawInfoMap.get(nodeSignature); 912 } 913 914 toString(): string { 915 let value = ''; 916 this.rawInfoMap.forEach((info, signature) => { 917 value += `[${signature}, ${info.line}]\n`; 918 }); 919 return value; 920 } 921} 922 923/** 924 * 报告结果实现类 925 */ 926export class LogReporterImpl implements LogReporter { 927 928 checkResults: Array<CheckLogResult> = []; 929 modifyResults: Array<ModifyLogResult> = []; 930 writer: LogWriter | undefined; 931 932 checkResultMap: Map<string, string> = new Map([ 933 ['filePath', '文件地址'], 934 ['apiName', '接口名称'], 935 ['apiContent', '接口内容'], 936 ['errorType', '异常类型'], 937 ['errorInfo', '告警信息'], 938 ['version', '版本号'], 939 ['moduleName', '模块名称'] 940 ]); 941 942 modifyResultMap: Map<string, string> = new Map([ 943 ['filePath', '文件地址'], 944 ['apiName', '接口名称'], 945 ['apiContent', '接口内容'], 946 ['modifyType', '整改类型'], 947 ['description', '整改内容'], 948 ['version', '版本号'], 949 ['description', '整改说明'], 950 ['moduleName', '模块名称'] 951 ]); 952 953 constructor() { } 954 955 setWriter(writer: LogWriter): void { 956 this.writer = writer; 957 } 958 959 addCheckResult(checkResult: CheckLogResult): void { 960 this.checkResults.push(checkResult); 961 } 962 963 addModifyResult(modifyResult: ModifyLogResult): void { 964 this.modifyResults.push(modifyResult); 965 } 966 967 getCheckResultMap(): Map<string, string> { 968 return this.checkResultMap; 969 } 970 971 getModifyResultMap(): Map<string, string> { 972 return this.modifyResultMap; 973 } 974 975 async writeCheckResults(path: string): Promise<void> { 976 await this.writer?.writeResults(this.checkResults, undefined, path); 977 } 978 979 async writeModifyResults(path: string): Promise<void> { 980 await this.writer?.writeResults(undefined, this.modifyResults, path); 981 } 982 983 async writeAllResults(path: string): Promise<void> { 984 await this.writer?.writeResults(this.checkResults, this.modifyResults, path); 985 } 986} 987 988export namespace logWriter { 989 export class ExcelWriter implements LogWriter { 990 workBook: excelJs.Workbook = new excelJs.Workbook(); 991 checkResultsColumns: Array<object> = []; 992 modifyResultsColumns: Array<object> = []; 993 994 constructor(logReporter: LogReporter) { 995 // 初始化列名 996 this.initCheckResultsColumns(logReporter.getCheckResultMap()); 997 this.initModifyResultsColumns(logReporter.getModifyResultMap()); 998 } 999 1000 initCheckResultsColumns(checkResultMap: Map<string, string>): void { 1001 checkResultMap.forEach((value: string, key: string) => { 1002 this.checkResultsColumns.push({ 1003 'header': value, 1004 'key': key 1005 }); 1006 }); 1007 } 1008 1009 initModifyResultsColumns(modifyResultMap: Map<string, string>): void { 1010 modifyResultMap.forEach((value: string, key: string) => { 1011 this.modifyResultsColumns.push({ 1012 'header': value, 1013 'key': key 1014 }); 1015 }); 1016 } 1017 1018 async writeResults(checkResults: Array<CheckLogResult> | undefined, 1019 modifyResults: Array<ModifyLogResult> | undefined, filePath: string): Promise<void> { 1020 if (checkResults) { 1021 const checkResultsSheet: excelJs.Worksheet = this.workBook.addWorksheet('待确认报告'); 1022 checkResultsSheet.columns = this.checkResultsColumns; 1023 checkResultsSheet.addRows(checkResults); 1024 } 1025 if (modifyResults) { 1026 const modifyResultsSheet: excelJs.Worksheet = this.workBook.addWorksheet('整改报告'); 1027 modifyResultsSheet.columns = this.modifyResultsColumns; 1028 modifyResultsSheet.addRows(modifyResults); 1029 } 1030 const fileName: string = `${filePath}.xlsx`; 1031 await this.workBook.xlsx.writeFile(fileName); 1032 } 1033 } 1034 1035 interface JsonResult { 1036 checkResults: Array<CheckLogResult> | undefined, 1037 modifyResults: Array<ModifyLogResult> | undefined 1038 } 1039 1040 export class JsonWriter implements LogWriter { 1041 async writeResults(checkResults: Array<CheckLogResult> | undefined, 1042 modifyResults: Array<ModifyLogResult> | undefined, filePath: string): Promise<void> { 1043 const results: JsonResult = { 1044 checkResults: undefined, 1045 modifyResults: undefined 1046 }; 1047 if (checkResults) { 1048 results.checkResults = checkResults; 1049 } 1050 if (modifyResults) { 1051 results.modifyResults = modifyResults; 1052 } 1053 const SPACE_NUMBER = 2; 1054 const jsonText: string = JSON.stringify(results, null, SPACE_NUMBER); 1055 const fileName: string = `${filePath}.json`; 1056 fs.writeFileSync(fileName, jsonText); 1057 } 1058 } 1059} 1060 1061export class LogResult { 1062 static createCheckResult(node: ts.Node, comments: Array<comment.CommentInfo>, errorInfo: string, 1063 context: Context | undefined, apiName: string, errorType: JSDocCheckErrorType): CheckLogResult { 1064 const url: string = context ? context.getInputFile() : ''; 1065 const moduleName: string = path.basename(url, ConstantValue.DTS_EXTENSION); 1066 const rawCodeInfo = context ? context.getRawSourceCodeInfo().findRawNodeInfo(node) : undefined; 1067 const filePath: string = `${url}(${rawCodeInfo?.line},${rawCodeInfo?.character})`; 1068 let version = 'N/A'; 1069 if (comments.length !== 0) { 1070 comments[comments.length - 1].commentTags.forEach((commentTag) => { 1071 if (commentTag.tag === 'since') { 1072 version = commentTag.name ? commentTag.name : 'N/A'; 1073 } 1074 }); 1075 } 1076 let apiContent: string = ''; 1077 comments.forEach((curComment: comment.CommentInfo) => { 1078 if (curComment.isApiComment) { 1079 apiContent += `${curComment.text}\n`; 1080 } 1081 }); 1082 apiContent += node.getText(); 1083 const checkLogResult: CheckLogResult = { 1084 filePath: filePath, 1085 apiName: apiName, 1086 apiContent: apiContent, 1087 errorType: errorType, 1088 version: version, 1089 errorInfo: errorInfo, 1090 moduleName: moduleName 1091 }; 1092 return checkLogResult; 1093 } 1094 1095 static createModifyResult(node: ts.Node, comments: Array<comment.CommentInfo>, description: string, 1096 context: Context | undefined, apiName: string, jsDocModifyType: JSDocModifyType): ModifyLogResult { 1097 const url: string = context ? context.getInputFile() : ''; 1098 const moduleName: string = path.basename(url, ConstantValue.DTS_EXTENSION); 1099 const rawCodeInfo = context ? context.getRawSourceCodeInfo().findRawNodeInfo(node) : undefined; 1100 const filePath: string = `${url}(${rawCodeInfo?.line},${rawCodeInfo?.character})`; 1101 let version = 'N/A'; 1102 if (comments.length !== 0) { 1103 comments[comments.length - 1].commentTags.forEach((commentTag) => { 1104 if (commentTag.tag === 'since') { 1105 version = commentTag.name ? commentTag.name : 'N/A'; 1106 } 1107 }); 1108 } 1109 let apiContent: string = ''; 1110 comments.forEach((curComment: comment.CommentInfo) => { 1111 if (curComment.isApiComment) { 1112 apiContent += `${curComment.text}\n`; 1113 } 1114 }); 1115 apiContent += node.getText(); 1116 const modifyLogResult: ModifyLogResult = { 1117 filePath: filePath, 1118 apiName: apiName, 1119 apiContent: apiContent, 1120 modifyType: jsDocModifyType, 1121 version: version, 1122 description: description, 1123 moduleName: moduleName 1124 }; 1125 return modifyLogResult; 1126 } 1127}