449 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			449 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| /**
 | |
|  * @license
 | |
|  * Copyright Google Inc. All Rights Reserved.
 | |
|  *
 | |
|  * Use of this source code is governed by an MIT-style license that can be
 | |
|  * found in the LICENSE file at https://angular.io/license
 | |
|  */
 | |
| 
 | |
| import * as path from 'path';
 | |
| import * as ts from 'typescript';
 | |
| 
 | |
| const baseTsOptions: ts.CompilerOptions = {
 | |
|   // We don't want symbols from external modules to be resolved, so we use the
 | |
|   // classic algorithm.
 | |
|   moduleResolution: ts.ModuleResolutionKind.Classic
 | |
| };
 | |
| 
 | |
| export interface JsDocTagOptions {
 | |
|   /**
 | |
|    * An array of names of jsdoc tags, one of which must exist. If no tags are provided, there are no
 | |
|    * required tags.
 | |
|    */
 | |
|   requireAtLeastOne?: string[];
 | |
| 
 | |
|   /**
 | |
|    * An array of names of jsdoc tags that must not exist.
 | |
|    */
 | |
|   banned?: string[];
 | |
| 
 | |
|   /**
 | |
|    * An array of names of jsdoc tags that will be copied to the serialized code.
 | |
|    */
 | |
|   toCopy?: string[];
 | |
| }
 | |
| 
 | |
| export interface SerializationOptions {
 | |
|   /**
 | |
|    * Removes all exports matching the regular expression.
 | |
|    */
 | |
|   stripExportPattern?: RegExp|RegExp[];
 | |
|   /**
 | |
|    * Allows these identifiers as modules in the output. For example,
 | |
|    * ```
 | |
|    * import * as angular from './angularjs';
 | |
|    *
 | |
|    * export class Foo extends angular.Bar {}
 | |
|    * ```
 | |
|    * will produce `export class Foo extends angular.Bar {}` and requires explicitly allowing
 | |
|    * `angular` as a module identifier.
 | |
|    */
 | |
|   allowModuleIdentifiers?: string[];
 | |
| 
 | |
|   /** The jsdoc tag options for top level exports */
 | |
|   exportTags?: JsDocTagOptions;
 | |
| 
 | |
|   /** The jsdoc tag options for properties/methods/etc of exports */
 | |
|   memberTags?: JsDocTagOptions;
 | |
| 
 | |
|   /** The jsdoc tag options for parameters of members/functions */
 | |
|   paramTags?: JsDocTagOptions;
 | |
| }
 | |
| 
 | |
| export type DiagnosticSeverity = 'warn'|'error'|'none';
 | |
| 
 | |
| export function publicApi(fileName: string, options: SerializationOptions = {}): string {
 | |
|   return publicApiInternal(ts.createCompilerHost(baseTsOptions), fileName, baseTsOptions, options);
 | |
| }
 | |
| 
 | |
| export function publicApiInternal(
 | |
|     host: ts.CompilerHost, fileName: string, tsOptions: ts.CompilerOptions,
 | |
|     options: SerializationOptions = {}): string {
 | |
|   // Since the entry point will be compared with the source files from the TypeScript program,
 | |
|   // the path needs to be normalized with forward slashes in order to work within Windows.
 | |
|   const entrypoint = path.normalize(fileName).replace(/\\/g, '/');
 | |
| 
 | |
|   // Setup default tag options
 | |
|   options = {
 | |
|     ...options,
 | |
|     exportTags: applyDefaultTagOptions(options.exportTags),
 | |
|     memberTags: applyDefaultTagOptions(options.memberTags),
 | |
|     paramTags: applyDefaultTagOptions(options.paramTags)
 | |
|   };
 | |
| 
 | |
|   if (!entrypoint.match(/\.d\.ts$/)) {
 | |
|     throw new Error(`Source file "${fileName}" is not a declaration file`);
 | |
|   }
 | |
| 
 | |
|   const program = ts.createProgram([entrypoint], tsOptions, host);
 | |
|   return new ResolvedDeclarationEmitter(program, entrypoint, options).emit();
 | |
| }
 | |
| 
 | |
| interface Diagnostic {
 | |
|   type?: DiagnosticSeverity;
 | |
|   message: string;
 | |
| }
 | |
| 
 | |
| class ResolvedDeclarationEmitter {
 | |
|   private program: ts.Program;
 | |
|   private fileName: string;
 | |
|   private typeChecker: ts.TypeChecker;
 | |
|   private options: SerializationOptions;
 | |
|   private diagnostics: Diagnostic[];
 | |
| 
 | |
|   constructor(program: ts.Program, fileName: string, options: SerializationOptions) {
 | |
|     this.program = program;
 | |
|     this.fileName = fileName;
 | |
|     this.options = options;
 | |
|     this.diagnostics = [];
 | |
| 
 | |
|     this.typeChecker = this.program.getTypeChecker();
 | |
|   }
 | |
| 
 | |
|   emit(): string {
 | |
|     const sourceFile = this.program.getSourceFiles().find(sf => sf.fileName === this.fileName);
 | |
|     if (!sourceFile) {
 | |
|       throw new Error(`Source file "${this.fileName}" not found`);
 | |
|     }
 | |
| 
 | |
|     let output: string[] = [];
 | |
| 
 | |
|     const resolvedSymbols = this.getResolvedSymbols(sourceFile);
 | |
|     // Sort all symbols so that the output is more deterministic
 | |
|     resolvedSymbols.sort(symbolCompareFunction);
 | |
| 
 | |
|     for (const symbol of resolvedSymbols) {
 | |
|       if (this.isExportPatternStripped(symbol.name)) {
 | |
|         continue;
 | |
|       }
 | |
| 
 | |
|       const typeDecl = symbol.declarations && symbol.declarations[0];
 | |
|       const valDecl = symbol.valueDeclaration;
 | |
|       if (!typeDecl && !valDecl) {
 | |
|         this.diagnostics.push({
 | |
|           type: 'warn',
 | |
|           message: `${sourceFile.fileName}: error: No declaration found for symbol "${symbol.name}"`
 | |
|         });
 | |
|         continue;
 | |
|       }
 | |
|       typeDecl && this.emitDeclaration(symbol, typeDecl, output);
 | |
|       if (valDecl && typeDecl.kind === ts.SyntaxKind.InterfaceDeclaration) {
 | |
|         // Only generate value declarations in case of interfaces.
 | |
|         valDecl && this.emitDeclaration(symbol, valDecl, output);
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     if (this.diagnostics.length) {
 | |
|       const message = this.diagnostics.map(d => d.message).join('\n');
 | |
|       console.warn(message);
 | |
|       if (this.diagnostics.some(d => d.type === 'error')) {
 | |
|         throw new Error(message);
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     return output.join('');
 | |
|   }
 | |
| 
 | |
|   emitDeclaration(symbol: ts.Symbol, decl: ts.Node, output: string[]) {
 | |
|     // The declaration node may not be a complete statement, e.g. for var/const
 | |
|     // symbols. We need to find the complete export statement by traversing
 | |
|     // upwards.
 | |
|     while (!hasModifier(decl, ts.SyntaxKind.ExportKeyword) && decl.parent) {
 | |
|       decl = decl.parent;
 | |
|     }
 | |
| 
 | |
|     if (hasModifier(decl, ts.SyntaxKind.ExportKeyword)) {
 | |
|       // Make an empty line between two exports
 | |
|       if (output.length) {
 | |
|         output.push('\n');
 | |
|       }
 | |
| 
 | |
|       const jsdocComment = this.processJsDocTags(decl, this.options.exportTags);
 | |
|       if (jsdocComment) {
 | |
|         output.push(jsdocComment + '\n');
 | |
|       }
 | |
| 
 | |
|       output.push(stripEmptyLines(this.emitNode(decl)) + '\n');
 | |
|     } else {
 | |
|       // This may happen for symbols re-exported from external modules.
 | |
|       this.diagnostics.push({
 | |
|         type: 'warn',
 | |
|         message: createErrorMessage(decl, `No export declaration found for symbol "${symbol.name}"`)
 | |
|       });
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   private isExportPatternStripped(symbolName: string): boolean {
 | |
|     return [].concat(this.options.stripExportPattern).some(p => !!(p && symbolName.match(p)));
 | |
|   }
 | |
| 
 | |
|   private getResolvedSymbols(sourceFile: ts.SourceFile): ts.Symbol[] {
 | |
|     const ms = (<any>sourceFile).symbol;
 | |
|     const rawSymbols = ms ? (this.typeChecker.getExportsOfModule(ms) || []) : [];
 | |
|     return rawSymbols.map(s => {
 | |
|       if (s.flags & ts.SymbolFlags.Alias) {
 | |
|         const resolvedSymbol = this.typeChecker.getAliasedSymbol(s);
 | |
| 
 | |
|         // This will happen, e.g. for symbols re-exported from external modules.
 | |
|         if (!resolvedSymbol.valueDeclaration && !resolvedSymbol.declarations) {
 | |
|           return s;
 | |
|         }
 | |
|         if (resolvedSymbol.name !== s.name) {
 | |
|           if (this.isExportPatternStripped(s.name)) {
 | |
|             return s;
 | |
|           }
 | |
|           throw new Error(
 | |
|               `Symbol "${resolvedSymbol.name}" was aliased as "${s.name}". ` +
 | |
|               `Aliases are not supported."`);
 | |
|         }
 | |
| 
 | |
|         return resolvedSymbol;
 | |
|       } else {
 | |
|         return s;
 | |
|       }
 | |
|     });
 | |
|   }
 | |
| 
 | |
|   emitNode(node: ts.Node) {
 | |
|     if (hasModifier(node, ts.SyntaxKind.PrivateKeyword)) {
 | |
|       return '';
 | |
|     }
 | |
| 
 | |
|     const firstQualifier: ts.Identifier|null = getFirstQualifier(node);
 | |
| 
 | |
|     if (firstQualifier) {
 | |
|       let isAllowed = false;
 | |
| 
 | |
|       // Try to resolve the qualifier.
 | |
|       const resolvedSymbol = this.typeChecker.getSymbolAtLocation(firstQualifier);
 | |
|       if (resolvedSymbol && resolvedSymbol.declarations && resolvedSymbol.declarations.length > 0) {
 | |
|         // If the qualifier can be resolved, and it's not a namespaced import, then it should be
 | |
|         // allowed.
 | |
|         isAllowed =
 | |
|             resolvedSymbol.declarations.every(decl => decl.kind !== ts.SyntaxKind.NamespaceImport);
 | |
|       }
 | |
| 
 | |
|       // If it is not allowed otherwise, it's allowed if it's on the list of allowed identifiers.
 | |
|       isAllowed = isAllowed ||
 | |
|           !(!this.options.allowModuleIdentifiers ||
 | |
|             this.options.allowModuleIdentifiers.indexOf(firstQualifier.text) < 0);
 | |
|       if (!isAllowed) {
 | |
|         this.diagnostics.push({
 | |
|           type: 'error',
 | |
|           message: createErrorMessage(
 | |
|               firstQualifier,
 | |
|               `Module identifier "${firstQualifier.text}" is not allowed. Remove it ` +
 | |
|                   `from source or allow it via --allowModuleIdentifiers.`)
 | |
|         });
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     let children: ts.Node[] = [];
 | |
|     if (ts.isFunctionDeclaration(node)) {
 | |
|       // Used ts.isFunctionDeclaration instead of node.kind because this is a type guard
 | |
|       const symbol = this.typeChecker.getSymbolAtLocation(node.name);
 | |
|       symbol.declarations.forEach(x => children = children.concat(x.getChildren()));
 | |
|     } else {
 | |
|       children = node.getChildren();
 | |
|     }
 | |
| 
 | |
|     const sourceText = node.getSourceFile().text;
 | |
|     if (children.length) {
 | |
|       // Sort declarations under a class or an interface
 | |
|       if (node.kind === ts.SyntaxKind.SyntaxList) {
 | |
|         switch (node.parent && node.parent.kind) {
 | |
|           case ts.SyntaxKind.ClassDeclaration:
 | |
|           case ts.SyntaxKind.InterfaceDeclaration: {
 | |
|             // There can be multiple SyntaxLists under a class or an interface,
 | |
|             // since SyntaxList is just an arbitrary data structure generated
 | |
|             // by Node#getChildren(). We need to check that we are sorting the
 | |
|             // right list.
 | |
|             if (children.every(node => node.kind in memberDeclarationOrder)) {
 | |
|               children = children.slice();
 | |
|               children.sort((a: ts.NamedDeclaration, b: ts.NamedDeclaration) => {
 | |
|                 // Static after normal
 | |
|                 return compareFunction(
 | |
|                            hasModifier(a, ts.SyntaxKind.StaticKeyword),
 | |
|                            hasModifier(b, ts.SyntaxKind.StaticKeyword)) ||
 | |
|                     // Our predefined order
 | |
|                     compareFunction(
 | |
|                            memberDeclarationOrder[a.kind], memberDeclarationOrder[b.kind]) ||
 | |
|                     // Alphebetical order
 | |
|                     // We need safe dereferencing due to edge cases, e.g. having two call signatures
 | |
|                     compareFunction((a.name || a).getText(), (b.name || b).getText());
 | |
|               });
 | |
|             }
 | |
|             break;
 | |
|           }
 | |
|         }
 | |
|       }
 | |
| 
 | |
|       let output: string = children.filter(x => x.kind !== ts.SyntaxKind.JSDocComment)
 | |
|                                .map(n => this.emitNode(n))
 | |
|                                .join('');
 | |
| 
 | |
|       // Print stability annotation for fields and parmeters
 | |
|       if (ts.isParameter(node) || node.kind in memberDeclarationOrder) {
 | |
|         const tagOptions = ts.isParameter(node) ? this.options.paramTags : this.options.memberTags;
 | |
|         const jsdocComment = this.processJsDocTags(node, tagOptions);
 | |
|         if (jsdocComment) {
 | |
|           // Add the annotation after the leading whitespace
 | |
|           output = output.replace(/^(\r?\n\s*)/, `$1${jsdocComment} `);
 | |
|         }
 | |
|       }
 | |
| 
 | |
|       return output;
 | |
|     } else {
 | |
|       const ranges = ts.getLeadingCommentRanges(sourceText, node.pos);
 | |
|       let tail = node.pos;
 | |
|       for (const range of ranges || []) {
 | |
|         if (range.end > tail) {
 | |
|           tail = range.end;
 | |
|         }
 | |
|       }
 | |
|       return sourceText.substring(tail, node.end);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   private processJsDocTags(node: ts.Node, tagOptions: JsDocTagOptions) {
 | |
|     const jsDocTags = getJsDocTags(node);
 | |
|     const requireAtLeastOne = tagOptions.requireAtLeastOne;
 | |
|     const isMissingAnyRequiredTag = requireAtLeastOne != null && requireAtLeastOne.length > 0 &&
 | |
|         jsDocTags.every(tag => requireAtLeastOne.indexOf(tag) === -1);
 | |
|     if (isMissingAnyRequiredTag) {
 | |
|       this.diagnostics.push({
 | |
|         type: 'error',
 | |
|         message: createErrorMessage(
 | |
|             node,
 | |
|             'Required jsdoc tags - One of the tags: ' +
 | |
|                 requireAtLeastOne.map(tag => `"@${tag}"`).join(', ') +
 | |
|                 ` - must exist on ${getName(node)}.`)
 | |
|       });
 | |
|     }
 | |
|     const bannedTagsFound =
 | |
|         tagOptions.banned.filter(bannedTag => jsDocTags.some(tag => tag === bannedTag));
 | |
|     if (bannedTagsFound.length) {
 | |
|       this.diagnostics.push({
 | |
|         type: 'error',
 | |
|         message: createErrorMessage(
 | |
|             node,
 | |
|             'Banned jsdoc tags - ' + bannedTagsFound.map(tag => `"@${tag}"`).join(', ') +
 | |
|                 ` - were found on ${getName(node)}.`)
 | |
|       });
 | |
|     }
 | |
|     const tagsToCopy =
 | |
|         jsDocTags.filter(tag => tagOptions.toCopy.some(tagToCopy => tag === tagToCopy));
 | |
| 
 | |
|     if (tagsToCopy.length === 1) {
 | |
|       return `/** @${tagsToCopy[0]} */`;
 | |
|     } else if (tagsToCopy.length > 1) {
 | |
|       return '/**\n' + tagsToCopy.map(tag => ` * @${tag}`).join('\n') + ' */\n';
 | |
|     } else {
 | |
|       return '';
 | |
|     }
 | |
|   }
 | |
| }
 | |
| 
 | |
| const tagRegex = /@(\w+)/g;
 | |
| 
 | |
| function getJsDocTags(node: ts.Node): string[] {
 | |
|   const sourceText = node.getSourceFile().text;
 | |
|   const trivia = sourceText.substr(node.pos, node.getLeadingTriviaWidth());
 | |
|   // We use a hash so that we don't collect duplicate jsdoc tags
 | |
|   // (e.g. if a property has a getter and setter with the same tag).
 | |
|   const jsdocTags: {[key: string]: boolean} = {};
 | |
|   let match: RegExpExecArray;
 | |
|   while (match = tagRegex.exec(trivia)) {
 | |
|     jsdocTags[match[1]] = true;
 | |
|   }
 | |
|   return Object.keys(jsdocTags);
 | |
| }
 | |
| 
 | |
| function symbolCompareFunction(a: ts.Symbol, b: ts.Symbol) {
 | |
|   return a.name.localeCompare(b.name);
 | |
| }
 | |
| 
 | |
| function compareFunction<T>(a: T, b: T) {
 | |
|   return a === b ? 0 : a > b ? 1 : -1;
 | |
| }
 | |
| 
 | |
| const memberDeclarationOrder: {[key: number]: number} = {
 | |
|   [ts.SyntaxKind.PropertySignature]: 0,
 | |
|   [ts.SyntaxKind.PropertyDeclaration]: 0,
 | |
|   [ts.SyntaxKind.GetAccessor]: 0,
 | |
|   [ts.SyntaxKind.SetAccessor]: 0,
 | |
|   [ts.SyntaxKind.CallSignature]: 1,
 | |
|   [ts.SyntaxKind.Constructor]: 2,
 | |
|   [ts.SyntaxKind.ConstructSignature]: 2,
 | |
|   [ts.SyntaxKind.IndexSignature]: 3,
 | |
|   [ts.SyntaxKind.MethodSignature]: 4,
 | |
|   [ts.SyntaxKind.MethodDeclaration]: 4
 | |
| };
 | |
| 
 | |
| function stripEmptyLines(text: string): string {
 | |
|   return text.split(/\r?\n/).filter(x => !!x.length).join('\n');
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Returns the first qualifier if the input node is a dotted expression.
 | |
|  */
 | |
| function getFirstQualifier(node: ts.Node): ts.Identifier|null {
 | |
|   switch (node.kind) {
 | |
|     case ts.SyntaxKind.PropertyAccessExpression: {
 | |
|       // For expression position
 | |
|       let lhs = node;
 | |
|       do {
 | |
|         lhs = (<ts.PropertyAccessExpression>lhs).expression;
 | |
|       } while (lhs && lhs.kind !== ts.SyntaxKind.Identifier);
 | |
| 
 | |
|       return <ts.Identifier>lhs;
 | |
|     }
 | |
|     case ts.SyntaxKind.TypeReference: {
 | |
|       // For type position
 | |
|       let lhs: ts.Node = (<ts.TypeReferenceNode>node).typeName;
 | |
|       do {
 | |
|         lhs = (<ts.QualifiedName>lhs).left;
 | |
|       } while (lhs && lhs.kind !== ts.SyntaxKind.Identifier);
 | |
| 
 | |
|       return <ts.Identifier>lhs;
 | |
|     }
 | |
|     default:
 | |
|       return null;
 | |
|   }
 | |
| }
 | |
| 
 | |
| function createErrorMessage(node: ts.Node, message: string): string {
 | |
|   const sourceFile = node.getSourceFile();
 | |
|   let position;
 | |
|   if (sourceFile) {
 | |
|     const {line, character} = sourceFile.getLineAndCharacterOfPosition(node.getStart());
 | |
|     position = `${sourceFile.fileName}(${line + 1},${character + 1})`;
 | |
|   } else {
 | |
|     position = '<unknown>';
 | |
|   }
 | |
| 
 | |
|   return `${position}: error: ${message}`;
 | |
| }
 | |
| 
 | |
| function hasModifier(node: ts.Node, modifierKind: ts.SyntaxKind): boolean {
 | |
|   return !!node.modifiers && node.modifiers.some(x => x.kind === modifierKind);
 | |
| }
 | |
| 
 | |
| function applyDefaultTagOptions(tagOptions: JsDocTagOptions|undefined): JsDocTagOptions {
 | |
|   return {requireAtLeastOne: [], banned: [], toCopy: [], ...tagOptions};
 | |
| }
 | |
| 
 | |
| function getName(node: any) {
 | |
|   return '`' + (node.name && node.name.text ? node.name.text : node.getText()) + '`';
 | |
| }
 |