UNPKG

tsickle

Version:

Transpile TypeScript code to JavaScript with Closure annotations.

1,234 lines (1,145 loc) 53.2 kB
/** * @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 {SourceMapGenerator} from 'source-map'; import * as ts from 'typescript'; import {hasExportingDecorator} from './decorators'; import * as jsdoc from './jsdoc'; import {getIdentifierText, Rewriter, unescapeName} from './rewriter'; import {Options} from './tsickle_compiler_host'; import {assertTypeChecked, TypeTranslator} from './type-translator'; import {toArray} from './util'; export {convertDecorators} from './decorator-annotator'; export {processES5} from './es5processor'; export {FileMap, ModulesManifest} from './modules_manifest'; export {Options, Pass, TsickleCompilerHost, TsickleHost} from './tsickle_compiler_host'; export interface Output { /** The TypeScript source with Closure annotations inserted. */ output: string; /** Generated externs declarations, if any. */ externs: string|null; /** Error messages, if any. */ diagnostics: ts.Diagnostic[]; /** A source map mapping back into the original sources. */ sourceMap: SourceMapGenerator; } /** * Symbols that are already declared as externs in Closure, that should * be avoided by tsickle's "declare ..." => externs.js conversion. */ export let closureExternsBlacklist: string[] = [ 'exports', 'global', 'module', // ErrorConstructor is the interface of the Error object itself. // tsickle detects that this is part of the TypeScript standard library // and assumes it's part of the Closure standard library, but this // assumption is wrong for ErrorConstructor. To properly handle this // we'd somehow need to map methods defined on the ErrorConstructor // interface into properties on Closure's Error object, but for now it's // simpler to just blacklist it. 'ErrorConstructor', 'Symbol', 'WorkerGlobalScope', ]; export function formatDiagnostics(diags: ts.Diagnostic[]): string { return diags .map((d) => { let res = ts.DiagnosticCategory[d.category]; if (d.file) { res += ' at ' + d.file.fileName + ':'; let {line, character} = d.file.getLineAndCharacterOfPosition(d.start); res += (line + 1) + ':' + (character + 1) + ':'; } res += ' ' + ts.flattenDiagnosticMessageText(d.messageText, '\n'); return res; }) .join('\n'); } /** @return true if node has the specified modifier flag set. */ function hasModifierFlag(node: ts.Node, flag: ts.ModifierFlags): boolean { return (ts.getCombinedModifierFlags(node) & flag) !== 0; } /** * TypeScript allows you to write identifiers quoted, like: * interface Foo { * 'bar': string; * 'complex name': string; * } * Foo.bar; // ok * Foo['bar'] // ok * Foo['complex name'] // ok * * In Closure-land, we want identify that the legal name 'bar' can become an * ordinary field, but we need to skip strings like 'complex name'. */ function isValidClosurePropertyName(name: string): boolean { // In local experimentation, it appears that reserved words like 'var' and // 'if' are legal JS and still accepted by Closure. return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name); } export function isDtsFileName(fileName: string): boolean { return /\.d\.ts$/.test(fileName); } /** Returns the Closure name of a function parameter, special-casing destructuring. */ function getParameterName(param: ts.ParameterDeclaration, index: number): string { switch (param.name.kind) { case ts.SyntaxKind.Identifier: let name = getIdentifierText(param.name as ts.Identifier); // TypeScript allows parameters named "arguments", but Closure // disallows this, even in externs. if (name === 'arguments') name = 'tsickle_arguments'; return name; case ts.SyntaxKind.ArrayBindingPattern: case ts.SyntaxKind.ObjectBindingPattern: // Closure crashes if you put a binding pattern in the externs. // Avoid this by just generating an unused name; the name is // ignored anyway. return `__${index}`; default: // The above list of kinds is exhaustive. param.name is 'never' at this point. let paramName = param.name as ts.Node; throw new Error(`unhandled function parameter kind: ${ts.SyntaxKind[paramName.kind]}`); } } const VISIBILITY_FLAGS: ts.ModifierFlags = ts.ModifierFlags.Private | ts.ModifierFlags.Protected | ts.ModifierFlags.Public; /** * A Rewriter subclass that adds Tsickle-specific (Closure translation) functionality. * * One Rewriter subclass manages .ts => .ts+Closure translation. * Another Rewriter subclass manages .ts => externs translation. */ class ClosureRewriter extends Rewriter { constructor(protected program: ts.Program, file: ts.SourceFile, protected options: Options) { super(file); } /** * Handles emittng the jsdoc for methods, including overloads. * If overloaded, merges the signatures in the list of SignatureDeclarations into a single jsdoc. * - Total number of parameters will be the maximum count found across all variants. * - Different names at the same parameter index will be joined with "_or_" * - Variable args (...type[] in TypeScript) will be output as "...type", * except if found at the same index as another argument. * @param fnDecls Pass > 1 declaration for overloads of same name * @return The list of parameter names that should be used to emit the actual * function statement; for overloads, name will have been merged. */ emitFunctionType(fnDecls: ts.SignatureDeclaration[], extraTags: jsdoc.Tag[] = []): string[] { const typeChecker = this.program.getTypeChecker(); let newDoc = extraTags; const lens = fnDecls.map(fnDecl => fnDecl.parameters.length); const minArgsCount = Math.min(...lens); const maxArgsCount = Math.max(...lens); const isConstructor = fnDecls.find(d => d.kind === ts.SyntaxKind.Constructor) !== undefined; // For each parameter index i, paramTags[i] is an array of parameters // that can be found at index i. E.g. // function foo(x: string) // function foo(y: number, z: string) // then paramTags[0] = [info about x, info about y]. const paramTags: jsdoc.Tag[][] = []; const returnTags: jsdoc.Tag[] = []; for (let fnDecl of fnDecls) { // Construct the JSDoc comment by reading the existing JSDoc, if // any, and merging it with the known types of the function // parameters and return type. let jsDoc = this.getJSDoc(fnDecl) || []; // Copy all the tags other than @param/@return into the new // JSDoc without any change; @param/@return are handled specially. // TODO: there may be problems if an annotation doesn't apply to all overloads; // is it worth checking for this and erroring? for (let tag of jsDoc) { if (tag.tagName === 'param' || tag.tagName === 'return') continue; newDoc.push(tag); } // Add @abstract on "abstract" declarations. if (hasModifierFlag(fnDecl, ts.ModifierFlags.Abstract)) { newDoc.push({tagName: 'abstract'}); } // Merge the parameters into a single list of merged names and list of types const sig = typeChecker.getSignatureFromDeclaration(fnDecl); for (let i = 0; i < sig.declaration.parameters.length; i++) { const paramNode = sig.declaration.parameters[i]; const name = getParameterName(paramNode, i); const isThisParam = name === 'this'; let newTag: jsdoc.Tag = { tagName: isThisParam ? 'this' : 'param', optional: paramNode.initializer !== undefined || paramNode.questionToken !== undefined, parameterName: isThisParam ? undefined : name, }; let type = typeChecker.getTypeAtLocation(paramNode); if (paramNode.dotDotDotToken !== undefined) { newTag.restParam = true; // In TypeScript you write "...x: number[]", but in Closure // you don't write the array: "@param {...number} x". Unwrap // the Array<> wrapper. type = (type as ts.TypeReference).typeArguments[0]; } newTag.type = this.typeToClosure(fnDecl, type); for (let {tagName, parameterName, text} of jsDoc) { if (tagName === 'param' && parameterName === newTag.parameterName) { newTag.text = text; break; } } if (!paramTags[i]) paramTags.push([]); paramTags[i].push(newTag); } // Return type. if (!isConstructor) { let retType = typeChecker.getReturnTypeOfSignature(sig); let retTypeString: string = this.typeToClosure(fnDecl, retType); let returnDoc: string|undefined; for (let {tagName, text} of jsDoc) { if (tagName === 'return') { returnDoc = text; break; } } returnTags.push({ tagName: 'return', type: retTypeString, text: returnDoc, }); } } // Merge the JSDoc tags for each overloaded parameter. let foundOptional = false; for (let i = 0; i < maxArgsCount; i++) { let paramTag = jsdoc.merge(paramTags[i]); // If any overload marks this param as a ..., mark it ... in the // merged output. if (paramTags[i].find(t => t.restParam === true) !== undefined) { paramTag.restParam = true; } // If any overload marks this param optional, mark it optional in the // merged output. Also mark parameters following optional as optional, // even if they are not, since Closure restricts this, see // https://github.com/google/closure-compiler/issues/2314 const optional = paramTags[i].find(t => t.optional === true) !== undefined || foundOptional; if (!paramTag.restParam && (optional || i >= minArgsCount)) { foundOptional = true; paramTag.type += '='; } newDoc.push(paramTag); } // Merge the JSDoc tags for each overloaded return. if (!isConstructor) { newDoc.push(jsdoc.merge(returnTags)); } this.emit('\n' + jsdoc.toString(newDoc)); return newDoc.filter(t => t.tagName === 'param').map(t => t.parameterName!); } /** * Returns null if there is no existing comment. */ getJSDoc(node: ts.Node): jsdoc.Tag[]|null { let text = node.getFullText(); let comments = ts.getLeadingCommentRanges(text, 0); if (!comments || comments.length === 0) return null; // JS compiler only considers the last comment significant. let {pos, end} = comments[comments.length - 1]; let comment = text.substring(pos, end); let parsed = jsdoc.parse(comment); if (!parsed) return null; if (parsed.warnings) { const start = node.getFullStart() + pos; this.diagnostics.push({ file: this.file, start, length: node.getStart() - start, messageText: parsed.warnings.join('\n'), category: ts.DiagnosticCategory.Warning, code: 0, }); } return parsed.tags; } /** Emits a type annotation in JSDoc, or {?} if the type is unavailable. */ emitJSDocType(node: ts.Node, additionalDocTag?: string, type?: ts.Type) { this.emit(' /**'); if (additionalDocTag) { this.emit(' ' + additionalDocTag); } this.emit(` @type {${this.typeToClosure(node, type)}} */`); } /** * Convert a TypeScript ts.Type into the equivalent Closure type. * * @param context The ts.Node containing the type reference; used for resolving symbols * in context. * @param type The type to translate; if not provided, the Node's type will be used. */ typeToClosure(context: ts.Node, type?: ts.Type): string { if (this.options.untyped) { return '?'; } let typeChecker = this.program.getTypeChecker(); if (!type) { type = typeChecker.getTypeAtLocation(context); } let translator = new TypeTranslator(typeChecker, context, this.options.typeBlackListPaths); translator.warn = msg => this.debugWarn(context, msg); return translator.translate(type); } /** * debug logs a debug warning. These should only be used for cases * where tsickle is making a questionable judgement about what to do. * By default, tsickle does not report any warnings to the caller, * and warnings are hidden behind a debug flag, as warnings are only * for tsickle to debug itself. */ debugWarn(node: ts.Node, messageText: string) { if (!this.options.logWarning) return; // Use a ts.Diagnosic so that the warning includes context and file offets. let diagnostic: ts.Diagnostic = { file: this.file, start: node.getStart(), length: node.getEnd() - node.getStart(), messageText, category: ts.DiagnosticCategory.Warning, code: 0, }; this.options.logWarning(diagnostic); } } /** Annotator translates a .ts to a .ts with Closure annotations. */ class Annotator extends ClosureRewriter { /** * Generated externs, if any. Any "declare" blocks encountered in the source * are forwarded to the ExternsWriter to be translated into externs. */ private externsWriter: ExternsWriter; /** Exported symbol names that have been generated by expanding an "export * from ...". */ private generatedExports = new Set<string>(); /** Externs determined by an exporting decorator. */ private exportingDecoratorExterns: string[] = []; constructor( program: ts.Program, file: ts.SourceFile, options: Options, private host?: ts.ModuleResolutionHost, private tsOpts?: ts.CompilerOptions) { super(program, file, options); this.externsWriter = new ExternsWriter(program, file, options); } annotate(): Output { this.visit(this.file); let externs = this.externsWriter.getOutput(); let annotated = this.getOutput(); let externsSource: string|null = null; if (externs.output.length > 0 || this.exportingDecoratorExterns.length > 0) { externsSource = `/** * @externs * @suppress {duplicate} */ // NOTE: generated by tsickle, do not edit. ` + externs.output + this.formatExportingDecoratorExterns(); } return { output: annotated.output, externs: externsSource, diagnostics: externs.diagnostics.concat(annotated.diagnostics), sourceMap: annotated.sourceMap, }; } getExportDeclarationNames(node: ts.Node): ts.Identifier[] { switch (node.kind) { case ts.SyntaxKind.VariableStatement: const varDecl = node as ts.VariableStatement; return varDecl.declarationList.declarations.map( (d) => this.getExportDeclarationNames(d)[0]); case ts.SyntaxKind.VariableDeclaration: case ts.SyntaxKind.FunctionDeclaration: case ts.SyntaxKind.InterfaceDeclaration: case ts.SyntaxKind.ClassDeclaration: const decl = node as ts.Declaration; if (!decl.name || decl.name.kind !== ts.SyntaxKind.Identifier) { break; } return [decl.name]; case ts.SyntaxKind.TypeAliasDeclaration: const typeAlias = node as ts.TypeAliasDeclaration; return [typeAlias.name]; default: break; } this.error( node, `unsupported export declaration ${ts.SyntaxKind[node.kind]}: ${node.getText()}`); return []; } /** * Emits an ES6 export for the ambient declaration behind node, if it is indeed exported. */ maybeEmitAmbientDeclarationExport(node: ts.Node) { // In TypeScript, `export declare` simply generates no code in the exporting module, but does // generate a regular import in the importing module. // For Closure Compiler, such declarations must still be exported, so that importing code in // other modules can reference them. Because tsickle generates global symbols for such types, // the appropriate semantics are referencing the global name. if (this.options.untyped || !hasModifierFlag(node, ts.ModifierFlags.Export)) { return; } const declNames = this.getExportDeclarationNames(node); for (let decl of declNames) { const sym = this.program.getTypeChecker().getSymbolAtLocation(decl); const isValue = sym.flags & ts.SymbolFlags.Value; const declName = getIdentifierText(decl); if (node.kind === ts.SyntaxKind.VariableStatement) { // For variables, TypeScript rewrites every reference to the variable name as an // "exports." access, to maintain mutable ES6 exports semantics. Indirecting through the // window object means we reference the correct global symbol. Closure Compiler does // understand that "var foo" in externs corresponds to "window.foo". this.emit(`\nexports.${declName} = window.${declName};\n`); } else if (!isValue) { // Non-value objects do not exist at runtime, so we cannot access the symbol (it only // exists in externs). Export them as a typedef, which forwards to the type in externs. this.emit(`\n/** @typedef {${declName}} */\nexports.${declName};\n`); } else { this.emit(`\nexports.${declName} = ${declName};\n`); } } } private formatExportingDecoratorExterns() { if (this.exportingDecoratorExterns.length === 0) { return ''; } return '\n' + this.exportingDecoratorExterns.map(name => `var ${name};\n`).join(''); } /** * Examines a ts.Node and decides whether to do special processing of it for output. * * @return True if the ts.Node has been handled, false if we should * emit it as is and visit its children. */ maybeProcess(node: ts.Node): boolean { if (hasModifierFlag(node, ts.ModifierFlags.Ambient) || isDtsFileName(this.file.fileName)) { this.externsWriter.visit(node); // An ambient declaration declares types for TypeScript's benefit, so we want to skip Tsickle // conversion of its contents. this.writeRange(node.getFullStart(), node.getEnd()); // ... but it might need to be exported for downstream importing code. this.maybeEmitAmbientDeclarationExport(node); return true; } if (hasExportingDecorator(node, this.program.getTypeChecker())) { let {name} = node as ( // If the node has a decorator, it must belong to one of these types. ts.ClassDeclaration | ts.MethodDeclaration | ts.PropertyDeclaration | ts.AccessorDeclaration); if (name) { this.exportingDecoratorExterns.push(name.getText()); } } switch (node.kind) { case ts.SyntaxKind.ImportDeclaration: return this.emitImportDeclaration(node as ts.ImportDeclaration); case ts.SyntaxKind.ExportDeclaration: let exportDecl = <ts.ExportDeclaration>node; this.writeRange(node.getFullStart(), node.getStart()); this.emit('export'); if (!exportDecl.exportClause && exportDecl.moduleSpecifier) { // It's an "export * from ..." statement. // Rewrite it to re-export each exported symbol directly. let exports = this.expandSymbolsFromExportStar(exportDecl); this.emit(` {${exports.join(',')}}`); } else { if (exportDecl.exportClause) this.visit(exportDecl.exportClause); } if (exportDecl.moduleSpecifier) { this.emit(' from'); this.writeModuleSpecifier(exportDecl.moduleSpecifier); } this.emit(';'); return true; case ts.SyntaxKind.InterfaceDeclaration: this.emitInterface(node as ts.InterfaceDeclaration); // Emit the TS interface verbatim, with no tsickle processing of properties. this.writeRange(node.getFullStart(), node.getEnd()); return true; case ts.SyntaxKind.VariableDeclaration: let varDecl = node as ts.VariableDeclaration; // Only emit a type annotation when it's a plain variable and // not a binding pattern, as Closure doesn't(?) have a syntax // for annotating binding patterns. See issue #128. if (varDecl.name.kind === ts.SyntaxKind.Identifier) { this.emitJSDocType(varDecl); } return false; case ts.SyntaxKind.ClassDeclaration: let classNode = <ts.ClassDeclaration>node; this.visitClassDeclaration(classNode); return true; case ts.SyntaxKind.PublicKeyword: case ts.SyntaxKind.PrivateKeyword: // The "public"/"private" keywords are encountered in two places: // 1) In class fields (which don't appear in the transformed output). // 2) In "parameter properties", e.g. // constructor(/** @export */ public foo: string). // In case 2 it's important to not emit that JSDoc in the generated // constructor, as this is illegal for Closure. It's safe to just // always skip comments preceding the 'public' keyword. // See test_files/parameter_properties.ts. this.writeNode(node, /* skipComments */ true); return true; case ts.SyntaxKind.Constructor: let ctor = <ts.ConstructorDeclaration>node; this.emitFunctionType([ctor]); // Write the "constructor(...) {" bit, but iterate through any // parameters if given so that we can examine them more closely. let offset = ctor.getStart(); if (ctor.parameters.length) { for (let param of ctor.parameters) { this.writeRange(offset, param.getFullStart()); this.visit(param); offset = param.getEnd(); } } this.writeRange(offset, node.getEnd()); return true; case ts.SyntaxKind.ArrowFunction: // It's difficult to annotate arrow functions due to a bug in // TypeScript (see tsickle issue 57). For now, just pass them // through unannotated. return false; case ts.SyntaxKind.FunctionDeclaration: case ts.SyntaxKind.MethodDeclaration: case ts.SyntaxKind.GetAccessor: case ts.SyntaxKind.SetAccessor: let fnDecl = <ts.FunctionLikeDeclaration>node; if (!fnDecl.body) { if (hasModifierFlag(fnDecl, ts.ModifierFlags.Abstract)) { this.emitFunctionType([fnDecl]); // Abstract functions look like // abstract foo(); // Emit the function as normal, except: // - remove the "abstract" // - change the return type to "void" // - replace the trailing semicolon with an empty block {} // To do so, skip all modifiers before the function name, and // emit up to the end of the parameter list / return type. if (!fnDecl.name) { // Can you even have an unnamed abstract function? this.error(fnDecl, 'anonymous abstract function'); return false; } this.writeRange(fnDecl.name.getStart(), fnDecl.parameters.end); this.emit(') {}'); return true; } // Functions are allowed to not have bodies in the presence // of overloads. It's not clear how to translate these overloads // into Closure types, so skip them for now. return false; } this.emitFunctionType([fnDecl]); this.writeRange(fnDecl.getStart(), fnDecl.body.getFullStart()); this.visit(fnDecl.body); return true; case ts.SyntaxKind.TypeAliasDeclaration: this.writeNode(node); this.visitTypeAlias(<ts.TypeAliasDeclaration>node); return true; case ts.SyntaxKind.EnumDeclaration: return this.maybeProcessEnum(<ts.EnumDeclaration>node); case ts.SyntaxKind.TypeAssertionExpression: case ts.SyntaxKind.AsExpression: // Both of these cases are AssertionExpressions. let typeAssertion = node as ts.AssertionExpression; this.emitJSDocType(typeAssertion); // When TypeScript emits JS, it removes one layer of "redundant" // parens, but we need them for the Closure type assertion. Work // around this by using two parens. See test_files/coerce.*. // TODO: the comment is currently dropped from pure assignments due to // https://github.com/Microsoft/TypeScript/issues/9873 this.emit('(('); this.writeNode(node); this.emit('))'); return true; case ts.SyntaxKind.NonNullExpression: const nnexpr = node as ts.NonNullExpression; let type = this.program.getTypeChecker().getTypeAtLocation(nnexpr.expression); if (type.flags & ts.TypeFlags.Union) { const nonNullUnion = (type as ts.UnionType) .types.filter( t => (t.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)) === 0); const typeCopy = Object.assign({}, type as ts.UnionType); typeCopy.types = nonNullUnion; type = typeCopy; } this.emitJSDocType(nnexpr, undefined, type); // See comment above. this.emit('(('); this.writeNode(nnexpr.expression); this.emit('))'); return true; case ts.SyntaxKind.PropertyDeclaration: const jsDoc = this.getJSDoc(node); if (jsDoc && jsDoc.length > 0 && node.getFirstToken()) { this.emit('\n'); this.emit(jsdoc.toString(jsDoc)); this.writeRange(node.getFirstToken().getStart(), node.getEnd()); return true; } break; default: break; } return false; } /** * Given a "export * from ..." statement, gathers the symbol names it actually * exports to be used in a statement like "export {foo, bar, baz} from ...". * * This is necessary because TS transpiles "export *" by just doing a runtime loop * over the target module's exports, which means Closure won't see the declarations/types * that are exported. */ private expandSymbolsFromExportStar(exportDecl: ts.ExportDeclaration): string[] { // You can't have an "export *" without a module specifier. const moduleSpecifier = exportDecl.moduleSpecifier!; let typeChecker = this.program.getTypeChecker(); // Gather the names of local exports, to avoid reexporting any // names that are already locally exported. // To find symbols declared like // export {foo} from ... // we must also query for "Alias", but that unfortunately also brings in // import {foo} from ... // so the latter is filtered below. let locals = typeChecker.getSymbolsInScope(this.file, ts.SymbolFlags.Export | ts.SymbolFlags.Alias); let localSet = new Set<string>(); for (let local of locals) { if (local.declarations && local.declarations.some(d => d.kind === ts.SyntaxKind.ImportSpecifier)) { continue; } localSet.add(local.name); } // Expand the export list, then filter it to the symbols we want to reexport. let exports = typeChecker.getExportsOfModule(typeChecker.getSymbolAtLocation(moduleSpecifier)); const reexports = new Set<string>(); for (let sym of exports) { let name = unescapeName(sym.name); if (localSet.has(name)) { // This name is shadowed by a local definition, such as: // - export var foo ... // - export {foo} from ... continue; } if (this.generatedExports.has(name)) { // Already exported via an earlier expansion of an "export * from ...". continue; } this.generatedExports.add(name); reexports.add(name); } return toArray(reexports.keys()); } /** * Convert from implicit `import {} from 'pkg'` to `import {} from 'pkg/index'. * TypeScript supports the shorthand, but not all ES6 module loaders do. * Workaround for https://github.com/Microsoft/TypeScript/issues/12597 */ private writeModuleSpecifier(moduleSpecifier: ts.Expression) { if (moduleSpecifier.kind !== ts.SyntaxKind.StringLiteral) { throw new Error(`unhandled moduleSpecifier kind: ${ts.SyntaxKind[moduleSpecifier.kind]}`); } let moduleId = (moduleSpecifier as ts.StringLiteral).text; if (this.options.convertIndexImportShorthand) { if (!this.tsOpts || !this.host) { throw new Error( 'option convertIndexImportShorthand requires that annotate be called with a TypeScript host and options.'); } const resolved = ts.resolveModuleName(moduleId, this.file.fileName, this.tsOpts, this.host); if (resolved && resolved.resolvedModule) { const resolvedModule = resolved.resolvedModule.resolvedFileName.replace(/(\.d)?\.ts$/, ''); const requestedModule = moduleId.replace(/\.js$/, ''); // If the imported module resolves to foo/index, but the specified module was foo, then we // append the /index. if (resolvedModule.substr(resolvedModule.length - 6) === '/index' && requestedModule.substr(requestedModule.length - 6) !== '/index') { moduleId += '/index'; } } } this.emit(` '${moduleId}'`); } /** * Handles emit of an "import ..." statement. * We need to do a bit of rewriting so that imported types show up under the * correct name in JSDoc. * @return true if the decl was handled, false to allow default processing. */ private emitImportDeclaration(decl: ts.ImportDeclaration): boolean { this.writeRange(decl.getFullStart(), decl.getStart()); this.emit('import'); const importClause = decl.importClause; if (!importClause) { // import './foo'; this.writeModuleSpecifier(decl.moduleSpecifier); this.emit(';'); return true; } else if ( importClause.name || (importClause.namedBindings && importClause.namedBindings.kind === ts.SyntaxKind.NamedImports)) { this.visit(importClause); this.emit(' from'); this.writeModuleSpecifier(decl.moduleSpecifier); this.emit(';'); // importClause.name implies // import a from ...; // namedBindings being NamedImports implies // import {a as b} from ...; // // Both of these forms create a local name "a", which after // TypeScript CommonJS compilation will become some renamed // variable like "module_1.a". But a user might still use plain // "a" in some JSDoc comment, so gather up these local names for // imports and make an alias for each for JSDoc purposes. if (!this.options.untyped) { let localNames: string[]; if (importClause.name) { // import a from ...; localNames = [getIdentifierText(importClause.name)]; } else { // import {a as b} from ...; const namedImports = importClause.namedBindings as ts.NamedImports; localNames = namedImports.elements.map(imp => getIdentifierText(imp.name)); } for (let name of localNames) { // This may look like a self-reference but TypeScript will rename the // right-hand side! this.emit( `\nconst ${name}: NeverTypeCheckMe = ${name}; /* local alias for Closure JSDoc */`); } } return true; } else if ( importClause.namedBindings && importClause.namedBindings.kind === ts.SyntaxKind.NamespaceImport) { // import * as foo from ...; this.visit(importClause); this.emit(' from'); this.writeModuleSpecifier(decl.moduleSpecifier); this.emit(';'); return true; } else { this.errorUnimplementedKind(decl, 'unexpected kind of import'); return false; // Use default processing. } } private visitClassDeclaration(classDecl: ts.ClassDeclaration) { let jsDoc = this.getJSDoc(classDecl) || []; if (hasModifierFlag(classDecl, ts.ModifierFlags.Abstract)) { jsDoc.push({tagName: 'abstract'}); } if (!this.options.untyped && classDecl.heritageClauses) { // If the class has "extends Foo", that is preserved in the ES6 output // and we don't need to do anything. But if it has "implements Foo", // that is a TS-specific thing and we need to translate it to the // the Closure "@implements {Foo}". for (const heritage of classDecl.heritageClauses) { if (!heritage.types) continue; if (heritage.token === ts.SyntaxKind.ImplementsKeyword) { for (const impl of heritage.types) { let tagName = 'implements'; // We can only @implements an interface, not a class. // But it's fine to translate TS "implements Class" into Closure // "@extends {Class}" because this is just a type hint. let typeChecker = this.program.getTypeChecker(); let sym = typeChecker.getSymbolAtLocation(impl.expression); if (sym.flags & ts.SymbolFlags.TypeAlias) { // It's implementing a type alias. Follow the type alias back // to the original symbol to check whether it's a type or a value. let type = typeChecker.getDeclaredTypeOfSymbol(sym); if (!type.symbol) { // It's not clear when this can happen, but if it does all we // do is fail to emit the @implements, which isn't so harmful. continue; } sym = type.symbol; } if (sym.flags & ts.SymbolFlags.Class) { tagName = 'extends'; } else if (sym.flags & ts.SymbolFlags.Value) { // If the symbol was already in the value namespace, then it will // not be a type in the Closure output (because Closure collapses // the type and value namespaces). Just ignore the implements. continue; } jsDoc.push({tagName, type: impl.getText()}); } } } } this.emit('\n'); if (jsDoc.length > 0) this.emit(jsdoc.toString(jsDoc)); if (classDecl.members.length > 0) { // We must visit all members individually, to strip out any // /** @export */ annotations that show up in the constructor // and to annotate methods. this.writeRange(classDecl.getStart(), classDecl.members[0].getFullStart()); for (let member of classDecl.members) { this.visit(member); } } else { this.writeRange(classDecl.getStart(), classDecl.getLastToken().getFullStart()); } this.writeNode(classDecl.getLastToken()); this.emitTypeAnnotationsHelper(classDecl); return true; } private emitInterface(iface: ts.InterfaceDeclaration) { if (this.options.untyped) return; // If this symbol is both a type and a value, we cannot emit both into Closure's // single namespace. let sym = this.program.getTypeChecker().getSymbolAtLocation(iface.name); if (sym.flags & ts.SymbolFlags.Value) return; this.emit(`\n/** @record */\n`); if (hasModifierFlag(iface, ts.ModifierFlags.Export)) this.emit('export '); let name = getIdentifierText(iface.name); this.emit(`function ${name}() {}\n`); if (iface.typeParameters) { this.emit(`// TODO: type parameters.\n`); } if (iface.heritageClauses) { this.emit(`// TODO: derived interfaces.\n`); } const memberNamespace = [name, 'prototype']; for (let elem of iface.members) { this.visitProperty(memberNamespace, elem); } } // emitTypeAnnotationsHelper produces a // _tsickle_typeAnnotationsHelper() where none existed in the // original source. It's necessary in the case where TypeScript // syntax specifies there are additional properties on the class, // because to declare these in Closure you must declare these in a // method somewhere. private emitTypeAnnotationsHelper(classDecl: ts.ClassDeclaration) { // Gather parameter properties from the constructor, if it exists. let ctors: ts.ConstructorDeclaration[] = []; let paramProps: ts.ParameterDeclaration[] = []; let nonStaticProps: ts.PropertyDeclaration[] = []; let staticProps: ts.PropertyDeclaration[] = []; for (let member of classDecl.members) { if (member.kind === ts.SyntaxKind.Constructor) { ctors.push(member as ts.ConstructorDeclaration); } else if (member.kind === ts.SyntaxKind.PropertyDeclaration) { let prop = member as ts.PropertyDeclaration; let isStatic = hasModifierFlag(prop, ts.ModifierFlags.Static); if (isStatic) { staticProps.push(prop); } else { nonStaticProps.push(prop); } } } if (ctors.length > 0) { let ctor = ctors[0]; paramProps = ctor.parameters.filter(p => hasModifierFlag(p, VISIBILITY_FLAGS)); } if (nonStaticProps.length === 0 && paramProps.length === 0 && staticProps.length === 0) { // There are no members so we don't need to emit any type // annotations helper. return; } if (!classDecl.name) return; let className = getIdentifierText(classDecl.name); this.emit(`\n\nfunction ${className}_tsickle_Closure_declarations() {\n`); staticProps.forEach(p => this.visitProperty([className], p)); let memberNamespace = [className, 'prototype']; nonStaticProps.forEach((p) => this.visitProperty(memberNamespace, p)); paramProps.forEach((p) => this.visitProperty(memberNamespace, p)); this.emit(`}\n`); } private propertyName(prop: ts.Declaration): string|null { if (!prop.name) return null; switch (prop.name.kind) { case ts.SyntaxKind.Identifier: return getIdentifierText(prop.name as ts.Identifier); case ts.SyntaxKind.StringLiteral: // E.g. interface Foo { 'bar': number; } // If 'bar' is a name that is not valid in Closure then there's nothing we can do. return (prop.name as ts.StringLiteral).text; default: return null; } } private visitProperty(namespace: string[], p: ts.Declaration) { let name = this.propertyName(p); if (!name) { this.emit(`/* TODO: handle strange member:\n${this.escapeForComment(p.getText())}\n*/\n`); return; } let tags = this.getJSDoc(p) || []; tags.push({tagName: 'type', type: this.typeToClosure(p)}); // Avoid printing annotations that can conflict with @type // This avoids Closure's error "type annotation incompatible with other annotations" this.emit(jsdoc.toString(tags, ['param', 'return'])); namespace = namespace.concat([name]); this.emit(`${namespace.join('.')};\n`); } private visitTypeAlias(node: ts.TypeAliasDeclaration) { if (this.options.untyped) return; // Write a Closure typedef, which involves an unused "var" declaration. // Note: in the case of an export, we cannot emit a literal "var" because // TypeScript drops exports that are never assigned to (and Closure // requires us to not assign to typedef exports). Instead, emit the // "exports.foo;" line directly in that case. this.emit(`\n/** @typedef {${this.typeToClosure(node)}} */\n`); if (hasModifierFlag(node, ts.ModifierFlags.Export)) { this.emit('exports.'); } else { this.emit('var '); } this.emit(`${node.name.getText()};\n`); } /** Processes an EnumDeclaration or returns false for ordinary processing. */ private maybeProcessEnum(node: ts.EnumDeclaration): boolean { if (hasModifierFlag(node, ts.ModifierFlags.Const)) { // const enums disappear after TS compilation and consequently need no // help from tsickle. return false; } // Gather the members of enum, saving the constant value or // initializer expression in the case of a non-constant value. let members = new Map<string, number|ts.Node>(); let i = 0; for (let member of node.members) { let memberName = member.name.getText(); if (member.initializer) { let enumConstValue = this.program.getTypeChecker().getConstantValue(member); if (enumConstValue !== undefined) { members.set(memberName, enumConstValue); i = enumConstValue + 1; } else { // Non-constant enum value. Save the initializer expression for // emitting as-is. // Note: if the member's initializer expression refers to another // value within the enum (e.g. something like // enum Foo { // Field1, // Field2 = Field1 + something(), // } // Then when we emit the initializer we produce invalid code because // on the Closure side it has to be written "Foo.Field1 + something()". // Hopefully this doesn't come up often -- if the enum instead has // something like // Field2 = Field1 + 3, // then it's still a constant expression and we inline the constant // value in the above branch of this "if" statement. members.set(memberName, member.initializer); } } else { members.set(memberName, i); i++; } } // Emit the enum declaration, which looks like: // type Foo = number; // let Foo: any = {}; // We use an "any" here rather than a more specific type because // we think TypeScript has already checked types for us, and it's // a bit difficult to provide a type that matches all the interfaces // expected of an enum (in particular, it is keyable both by // string and number). // We don't emit a specific Closure type for the enum because it's // also difficult to make work: for example, we can't make the name // both a typedef and an indexable object if we export it. this.emit('\n'); let name = node.name.getText(); const isExported = hasModifierFlag(node, ts.ModifierFlags.Export); if (isExported) this.emit('export '); this.emit(`type ${name} = number;\n`); if (isExported) this.emit('export '); this.emit(`let ${name}: any = {};\n`); // Emit foo.BAR = 0; lines. for (let member of toArray(members.keys())) { if (!this.options.untyped) this.emit(`/** @type {number} */\n`); this.emit(`${name}.${member} = `); let value = members.get(member)!; if (typeof value === 'number') { this.emit(value.toString()); } else { this.visit(value); } this.emit(';\n'); } // Emit foo[foo.BAR] = 'BAR'; lines. for (let member of toArray(members.keys())) { this.emit(`${name}[${name}.${member}] = "${member}";\n`); } return true; } } /** ExternsWriter generates Closure externs from TypeScript source. */ class ExternsWriter extends ClosureRewriter { /** visit is the main entry point. It generates externs from a ts.Node. */ public visit(node: ts.Node, namespace: string[] = []) { switch (node.kind) { case ts.SyntaxKind.SourceFile: let sourceFile = node as ts.SourceFile; for (let stmt of sourceFile.statements) { this.visit(stmt, namespace); } break; case ts.SyntaxKind.ModuleDeclaration: let decl = <ts.ModuleDeclaration>node; switch (decl.name.kind) { case ts.SyntaxKind.Identifier: { // E.g. "declare namespace foo {" let name = getIdentifierText(decl.name as ts.Identifier); if (name === undefined) break; if (this.isFirstDeclaration(decl)) { this.emit('/** @const */\n'); this.writeExternsVariable(name, namespace, '{}'); } if (decl.body) this.visit(decl.body, namespace.concat(name)); } break; case ts.SyntaxKind.StringLiteral: { // E.g. "declare module 'foo' {" (note the quotes). // We still want to emit externs for this module, but // Closure doesn't really provide a mechanism for // module-scoped externs. For now, ignore the enclosing // namespace (because this is declaring a top-level module) // and emit into a fake namespace. namespace = ['tsickle_declare_module']; let name = (decl.name as ts.StringLiteral).text; this.emit('/** @const */\n'); this.writeExternsVariable(name, namespace, '{}'); if (decl.body) this.visit(decl.body, namespace.concat(name)); } break; default: this.errorUnimplementedKind(decl.name, 'externs generation of namespace'); } break; case ts.SyntaxKind.ModuleBlock: let block = <ts.ModuleBlock>node; for (let stmt of block.statements) { this.visit(stmt, namespace); } break; case ts.SyntaxKind.ClassDeclaration: case ts.SyntaxKind.InterfaceDeclaration: this.writeExternsType(<ts.InterfaceDeclaration|ts.ClassDeclaration>node, namespace); break; case ts.SyntaxKind.FunctionDeclaration: const fnDecl = node as ts.FunctionDeclaration; const name = fnDecl.name; if (!name) { this.error(fnDecl, 'anonymous function in externs'); break; } // Gather up all overloads of this function. const sym = this.program.getTypeChecker().getSymbolAtLocation(name); const decls = sym.declarations!.filter( d => d.kind === ts.SyntaxKind.FunctionDeclaration) as ts.FunctionDeclaration[]; // Only emit the first declaration of each overloaded function. if (fnDecl !== decls[0]) break; const params = this.emitFunctionType(decls); this.writeExternsFunction(name.getText(), params, namespace); break; case ts.SyntaxKind.VariableStatement: for (let decl of (<ts.VariableStatement>node).declarationList.declarations) { this.writeExternsVariableDecl(decl, namespace); } break; case ts.SyntaxKind.EnumDeclaration: this.writeExternsEnum(node as ts.EnumDeclaration, namespace); break; case ts.SyntaxKind.TypeAliasDeclaration: this.writeExternsTypeAlias(node as ts.TypeAliasDeclaration, namespace); break; default: this.emit(`\n/* TODO: ${ts.SyntaxKind[node.kind]} in ${namespace.join('.')} */\n`); break; } } /** * isFirstDeclaration returns true if decl is the first declaration * of its symbol. E.g. imagine * interface Foo { x: number; } * interface Foo { y: number; } * we only want to emit the "@record" for Foo on the first one. */ private isFirstDeclaration(decl: ts.DeclarationStatement): boolean { if (!decl.name) return true; const typeChecker = this.program.getTypeChecker(); const sym = typeChecker.getSymbolAtLocation(decl.name); if (!sym.declarations || sym.declarations.length < 2) return true; return decl === sym.declarations[0]; } private writeExternsType(decl: ts.InterfaceDeclaration|ts.ClassDeclaration, namespace: string[]) { const name = decl.name; if (!name) { this.error(decl, 'anonymous type in externs'); return; } let typeName = namespace.concat([name.getText()]).join('.'); if (closureExternsBlacklist.indexOf(typeName) >= 0) return; if (this.isFirstDeclaration(decl)) { let paramNames: string[] = []; if (decl.kind === ts.SyntaxKind.ClassDeclaration) { let ctors = (<ts.ClassDeclaration>decl).members.filter((m) => m.kind === ts.SyntaxKind.Constructor); if (ctors.length) { let firstCtor: ts.ConstructorDeclaration = <ts.ConstructorDeclaration>ctors[0]; const ctorTags = [{tagName: 'constructor'}, {tagName: 'struct'}]; if (ctors.length > 1) { paramNames = this.emitFunctionType(ctors as ts.ConstructorDeclaration[], ctorTags); } else { paramNames = this.emitFunctionType([firstCtor], ctorTags); } } else { this.emit('\n/** @constructor @struct */\n'); } } else { this.emit('\n/** @record @struct */\n'); } this.writeExternsFunction(name.getText(), paramNames, namespace); } // Process everything except (MethodSignature|MethodDeclaration|Constructor) let methods: Map<string, ts.MethodDeclaration[]> = new Map(); for (let member of decl.members) { switch (member.kind) { case ts.SyntaxKind.PropertySignature: case ts.SyntaxKind.PropertyDeclaration: let prop = <ts.PropertySignature>member; if (prop.name.kind === ts.SyntaxKind.Identifier) { this.emitJSDocType(prop); this.emit(`\n${typeName}.prototype.${prop.name.getText()};\n`); continue; } // TODO: For now property names other than Identifiers are not handled; e.g. // interface Foo { "123bar": number } break; case ts.SyntaxKind.MethodSignature: case ts.SyntaxKind.MethodDeclaration: const method = member as ts.MethodDeclaration; const methodName = method.name.getText(); if (methods.has(methodName)) { methods.get(methodName)!.push(method); } else { methods.set(methodName, [method]); } continue; case ts.SyntaxKind.Constructor: continue; // Handled above. default: // Members can include things like index signatures, for e.g. // interface Foo { [key: string]: number; } // For now, just skip it. break; } // If we get here, the member wasn't handled in the switch statement. let memberName = namespace; if (member.name) { memberName = memberName.concat([member.name.getText()]); } this.emit(`\n/* TODO: ${ts.SyntaxKind[member.kind]}: ${memberName.joi