UNPKG

tsickle

Version:

Transpile TypeScript code to JavaScript with Closure annotations.

591 lines 28.8 kB
"use strict"; /** * @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 */ Object.defineProperty(exports, "__esModule", { value: true }); exports.ModuleTypeTranslator = exports.MutableJSDoc = void 0; /** * @fileoverview module_type_translator builds on top of type_translator, adding functionality to * translate types within the scope of a single module. The main entry point is * ModuleTypeTranslator. */ const ts = require("typescript"); const googmodule = require("./googmodule"); const jsdoc = require("./jsdoc"); const transformer_util_1 = require("./transformer_util"); const typeTranslator = require("./type_translator"); /** * MutableJSDoc encapsulates a (potential) JSDoc comment on a specific node, and allows code to * modify (including delete) it. */ class MutableJSDoc { constructor(node, sourceComment, tags) { this.node = node; this.sourceComment = sourceComment; this.tags = tags; } updateComment(escapeExtraTags) { const text = jsdoc.toStringWithoutStartEnd(this.tags, escapeExtraTags); if (this.sourceComment) { if (!text) { // Delete the (now empty) comment. const comments = ts.getSyntheticLeadingComments(this.node); const idx = comments.indexOf(this.sourceComment); comments.splice(idx, 1); this.sourceComment = null; return; } this.sourceComment.text = text; return; } // Don't add an empty comment. if (!text) return; const comment = { kind: ts.SyntaxKind.MultiLineCommentTrivia, text, hasTrailingNewLine: true, pos: -1, end: -1, }; const comments = ts.getSyntheticLeadingComments(this.node) || []; comments.push(comment); ts.setSyntheticLeadingComments(this.node, comments); } } exports.MutableJSDoc = MutableJSDoc; /** Returns the Closure name of a function parameter, special-casing destructuring. */ function getParameterName(param, index) { switch (param.name.kind) { case ts.SyntaxKind.Identifier: let name = (0, transformer_util_1.getIdentifierText)(param.name); // 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. const paramName = param.name; throw new Error(`unhandled function parameter kind: ${ts.SyntaxKind[paramName.kind]}`); } } /** * ModuleTypeTranslator encapsulates knowledge and helper functions to translate types in the scope * of a specific module. This includes managing Closure requireType statements and any symbol * aliases in scope for a whole file. */ class ModuleTypeTranslator { constructor(sourceFile, typeChecker, host, diagnostics, isForExterns) { var _a; this.sourceFile = sourceFile; this.typeChecker = typeChecker; this.host = host; this.diagnostics = diagnostics; this.isForExterns = isForExterns; /** * A mapping of aliases for symbols in the current file, used when emitting types. TypeScript * emits imported symbols with unpredictable prefixes. To generate correct type annotations, * tsickle creates its own aliases for types, and registers them in this map (see * `emitImportDeclaration` and `requireType()` below). The aliases are then used when emitting * types. */ this.symbolsToAliasedNames = new Map(); /** * A cache for expensive symbol lookups, see TypeTranslator.symbolToString. Maps symbols to their * Closure name in this file scope. */ this.symbolToNameCache = new Map(); /** * The set of module symbols requireTyped in the local namespace. This tracks which imported * modules we've already added to additionalImports below. */ this.requireTypeModules = new Set(); /** * The list of generated goog.requireType statements for this module. These are inserted into * the module's body statements after translation. */ this.additionalImports = []; // TODO: remove once AnnotatorHost.typeBlackListPaths is removed. // tslint:disable-next-line:deprecation this.host.unknownTypesPaths = (_a = this.host.unknownTypesPaths) !== null && _a !== void 0 ? _a : this.host.typeBlackListPaths; } debugWarn(context, messageText) { (0, transformer_util_1.reportDebugWarning)(this.host, context, messageText); } error(node, messageText) { (0, transformer_util_1.reportDiagnostic)(this.diagnostics, node, messageText); } /** * 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, type) { if (this.host.untyped) { return '?'; } const typeChecker = this.typeChecker; if (!type) { type = typeChecker.getTypeAtLocation(context); } try { return this.newTypeTranslator(context).translate(type); } catch (e) { if (!(e instanceof Error)) throw e; // should not happen (tm) const sourceFile = context.getSourceFile(); const { line, character } = context.pos !== -1 ? sourceFile.getLineAndCharacterOfPosition(context.pos) : { line: 0, character: 0 }; e.message = `internal error converting type at ${sourceFile.fileName}:${line}:${character}:\n\n` + e.message; throw e; } } newTypeTranslator(context) { // In externs, there is no local scope, so all types must be relative to the file level scope. const translationContext = this.isForExterns ? this.sourceFile : context; const translator = new typeTranslator.TypeTranslator(this.host, this.typeChecker, translationContext, this.host.unknownTypesPaths || new Set(), this.symbolsToAliasedNames, this.symbolToNameCache, (sym) => void this.ensureSymbolDeclared(sym)); translator.isForExterns = this.isForExterns; translator.warn = msg => void this.debugWarn(context, msg); return translator; } isAlwaysUnknownSymbol(context) { const type = this.typeChecker.getTypeAtLocation(context); let sym = type.symbol; if (!sym) return false; if (sym.flags & ts.SymbolFlags.Alias) { sym = this.typeChecker.getAliasedSymbol(sym); } return this.newTypeTranslator(context).isAlwaysUnknownSymbol(sym); } /** * Get the ts.Symbol at a location or throw. * The TypeScript API can return undefined when fetching a symbol, but in many contexts we know it * won't (e.g. our input is already type-checked). */ mustGetSymbolAtLocation(node) { const sym = this.typeChecker.getSymbolAtLocation(node); if (!sym) throw new Error('no symbol'); return sym; } /** Finds an exported (i.e. not global) declaration for the given symbol. */ findExportedDeclaration(sym) { // TODO(martinprobst): it's unclear when a symbol wouldn't have a declaration, maybe just for // some builtins (e.g. Symbol)? if (!sym.declarations || sym.declarations.length === 0) return undefined; // A symbol declared in this file does not need to be imported. if (sym.declarations.some(d => d.getSourceFile() === this.sourceFile)) return undefined; // Find an exported declaration. // Because tsickle runs with the --declaration flag, all types referenced from exported types // must be exported, too, so there must either be some declaration that is exported, or the // symbol is actually a global declaration (declared in a script file, not a module). const decl = sym.declarations.find(d => { // Check for Export | Default (default being a default export). if (!(0, transformer_util_1.hasModifierFlag)(d, ts.ModifierFlags.ExportDefault)) return false; // Exclude symbols declared in `declare global {...}` blocks, they are global and don't need // imports. let current = d; while (current) { if (current.flags & ts.NodeFlags.GlobalAugmentation) return false; current = current.parent; } return true; }); return decl; } /** * Generates a somewhat human-readable module prefix for the given import context, to make * debugging the emitted Closure types a bit easier. */ generateModulePrefix(importPath) { const modulePrefix = importPath.replace(/(\/index)?(\.d)?\.[tj]sx?$/, '') .replace(/^.*[/.](.+?)/, '$1') .replace(/\W/g, '_'); return `tsickle_${modulePrefix || 'reqType'}_`; } /** * Records that we we want a `const x = goog.requireType...` import of the given `importPath`, * which will be inserted when we emit. * This also registers aliases for symbols from the module that map to this requireType. * * @param isDefaultImport True if the import statement is a default import, e.g. * `import Foo from ...;`, which matters for adjusting whether we emit a `.default`. */ requireType(context, importPath, moduleSymbol, isDefaultImport = false) { if (this.host.untyped) return; // Already imported? Do not emit a duplicate requireType. if (this.requireTypeModules.has(moduleSymbol)) return; if (typeTranslator.isAlwaysUnknownSymbol(this.host.unknownTypesPaths, moduleSymbol)) { return; // Do not emit goog.requireType for paths marked as always unknown. } const nsImport = googmodule.namespaceForImportUrl(context, this.diagnostics, importPath, moduleSymbol); const requireTypePrefix = this.generateModulePrefix(importPath) + String(this.requireTypeModules.size + 1); const moduleNamespace = nsImport !== null ? nsImport : this.host.pathToModuleName(this.sourceFile.fileName, importPath); // In TypeScript, importing a module for use in a type annotation does not cause a runtime load. // In Closure Compiler, goog.require'ing a module causes a runtime load, so emitting requires // here would cause a change in load order, which is observable (and can lead to errors). // Instead, goog.requireType types, which allows using them in type annotations without // causing a load. // const requireTypePrefix = goog.requireType(moduleNamespace) this.additionalImports.push(ts.factory.createVariableStatement(undefined, ts.factory.createVariableDeclarationList([ts.factory.createVariableDeclaration(requireTypePrefix, /* exclamationToken */ undefined, /* type */ undefined, ts.factory.createCallExpression(ts.factory.createPropertyAccessExpression(ts.factory.createIdentifier('goog'), 'requireType'), undefined, [ts.factory.createStringLiteral(moduleNamespace)]))], ts.NodeFlags.Const))); this.requireTypeModules.add(moduleSymbol); this.registerImportAliases(nsImport, isDefaultImport, moduleSymbol, () => requireTypePrefix); } /** * Registers aliases for the given import. * * @param googNamespace The goog: namespace as returned from * googmodule.namespaceForImportUrl. * @param isDefaultImport True if the import statement is a default import, * e.g. `import Foo from ...;`, which matters for adjusting whether we * emit a `.default`. * @param moduleSymbol Symbol of the imported module, e.g. as returned from * typeChecker.getSymbolAtLocation(importDeclaration.moduleSpecifier). * @param getAliasPrefix Should return the alias prefix. Called for each * exported symbol. The registered alias is <aliasPrefix>.<exportedName>. */ registerImportAliases(googNamespace, isDefaultImport, moduleSymbol, getAliasPrefix) { if (googmodule.extractModuleMarker(moduleSymbol, '__clutz_strip_property')) { // Symbols using import-by-path with strip property should be mapped to a // default import. This makes sure that type annotations get emitted as // "@type {module_alias}", not "@type {module_alias.TheStrippedName}". isDefaultImport = true; } for (let sym of this.typeChecker.getExportsOfModule(moduleSymbol)) { const aliasPrefix = getAliasPrefix(sym); // Some users import {default as SomeAlias} from 'goog:...'; // The code below must recognize this as a default import to alias the // symbol to just the blank module name. const namedDefaultImport = sym.name === 'default'; // goog: imports don't actually use the .default property that TS thinks // they have. const qualifiedName = googNamespace && (isDefaultImport || namedDefaultImport) ? aliasPrefix : aliasPrefix + '.' + sym.name; if (sym.flags & ts.SymbolFlags.Alias) { sym = this.typeChecker.getAliasedSymbol(sym); } this.symbolsToAliasedNames.set(sym, qualifiedName); } } ensureSymbolDeclared(sym) { // Early exit if we already have a local alias. // This also prevents "ensureSymbolDeclared" from clobbering local aliases // set up for imports. if (this.symbolsToAliasedNames.has(sym)) return; const decl = this.findExportedDeclaration(sym); if (!decl) return; if (this.isForExterns) { this.error(decl, `declaration from module used in ambient type: ${sym.name}`); return; } // Actually import the symbol. const sourceFile = decl.getSourceFile(); if (sourceFile === ts.getOriginalNode(this.sourceFile)) return; const moduleSymbol = this.typeChecker.getSymbolAtLocation(sourceFile); // A source file might not have a symbol if it's not a module (no ES6 im/exports). if (!moduleSymbol) return; // TODO(martinprobst): this should possibly use fileNameToModuleId. this.requireType(decl, sourceFile.fileName, moduleSymbol); } insertAdditionalImports(sourceFile) { let insertion = 0; // Skip over a leading file comment holder. if (sourceFile.statements.length && sourceFile.statements[0].kind === ts.SyntaxKind.NotEmittedStatement) { insertion++; } return ts.factory.updateSourceFile(sourceFile, [ ...sourceFile.statements.slice(0, insertion), ...this.additionalImports, ...sourceFile.statements.slice(insertion), ]); } /** * Parses and synthesizes comments on node, and returns the JSDoc from it, if any. * @param reportWarnings if true, will report warnings from parsing the JSDoc. Set to false if * this is not the "main" location dealing with a node to avoid duplicated warnings. */ getJSDoc(node, reportWarnings) { if (!ts.getParseTreeNode(node)) return []; const [tags,] = this.parseJSDoc(node, reportWarnings); return tags; } getMutableJSDoc(node) { const [tags, comment] = this.parseJSDoc(node, /* reportWarnings */ true); return new MutableJSDoc(node, comment, tags); } parseJSDoc(node, reportWarnings) { // synthesizeLeadingComments below changes text locations for node, so extract the location here // in case it is needed later to report diagnostics. const start = node.getFullStart(); const length = node.getLeadingTriviaWidth(this.sourceFile); const comments = jsdoc.synthesizeLeadingComments(node); if (!comments || comments.length === 0) return [[], null]; for (let i = comments.length - 1; i >= 0; i--) { const comment = comments[i]; const parsed = jsdoc.parse(comment); if (parsed) { if (reportWarnings && parsed.warnings) { const range = comment.originalRange || { pos: start, end: start + length }; (0, transformer_util_1.reportDiagnostic)(this.diagnostics, node, parsed.warnings.join('\n'), range, ts.DiagnosticCategory.Warning); } return [parsed.tags, comment]; } } return [[], null]; } /** * resolveRestParameterType resolves the array member type for a rest parameter ("..."). * In TypeScript you write "...x: number[]", but in Closure you don't write the array: * `@param {...number} x`. The code below unwraps the Array<> wrapper. */ resolveRestParameterType(newTag, fnDecl, paramNode) { const type = typeTranslator.restParameterType(this.typeChecker, this.typeChecker.getTypeAtLocation(paramNode)); newTag.restParam = true; if (!type) { // If we fail to unwrap the Array<> type, emit an unknown type. this.debugWarn(paramNode, 'failed to resolve rest parameter type, emitting ?'); newTag.type = '?'; return; } newTag.type = this.typeToClosure(fnDecl, type); } /** * Creates 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. */ getFunctionTypeJSDoc(fnDecls, extraTags = []) { const typeChecker = this.typeChecker; // De-duplicate tags and docs found for the fnDecls. const tagsByName = new Map(); function addTag(tag) { if (tag.tagName === 'implements') return; // implements cannot be merged. const existing = tagsByName.get(tag.tagName); tagsByName.set(tag.tagName, existing ? jsdoc.merge([existing, tag]) : tag); } for (const extraTag of extraTags) addTag(extraTag); 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 = []; const returnTags = []; const thisTags = []; const typeParameterNames = new Set(); const argCounts = []; let thisReturnType = null; for (const 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. const tags = this.getJSDoc(fnDecl, /* reportWarnings */ false); // 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 (const tag of tags) { if (tag.tagName === 'param' || tag.tagName === 'return') continue; addTag(tag); } const flags = ts.getCombinedModifierFlags(fnDecl); // Add @abstract on "abstract" declarations. if (flags & ts.ModifierFlags.Abstract) { addTag({ tagName: 'abstract' }); } // Add @protected/@private if present, but not to function declarations, // function expressions, nor arrow functions (who are not class members, // so visibility does not apply). if (fnDecls.every(d => !ts.isFunctionDeclaration(d) && !ts.isFunctionExpression(d) && !ts.isArrowFunction(d))) { if (flags & ts.ModifierFlags.Protected) { addTag({ tagName: 'protected' }); } else if (flags & ts.ModifierFlags.Private) { addTag({ tagName: 'private' }); } else if (!tagsByName.has('export') && !tagsByName.has('package')) { // TODO(b/202495167): remove the 'package' check above. addTag({ tagName: 'public' }); } } // Add any @template tags. // Multiple declarations with the same template variable names should work: // the declarations get turned into union types, and Closure Compiler will need // to find a union where all type arguments are satisfied. if (fnDecl.typeParameters) { for (const tp of fnDecl.typeParameters) { typeParameterNames.add((0, transformer_util_1.getIdentifierText)(tp.name)); } } // Merge the parameters into a single list of merged names and list of types const sig = typeChecker.getSignatureFromDeclaration(fnDecl); if (!sig || !sig.declaration) throw new Error(`invalid signature ${fnDecl.name}`); if (sig.declaration.kind === ts.SyntaxKind.JSDocSignature) { throw new Error(`JSDoc signature ${fnDecl.name}`); } let hasThisParam = false; 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'; if (isThisParam) hasThisParam = true; const newTag = { tagName: isThisParam ? 'this' : 'param', optional: paramNode.initializer !== undefined || paramNode.questionToken !== undefined, parameterName: isThisParam ? undefined : name, }; if (paramNode.dotDotDotToken === undefined) { // The simple case: a plain parameter type. newTag.type = this.typeToClosure(fnDecl, this.typeChecker.getTypeAtLocation(paramNode)); } else { // The complex case: resolve the array member type in ...foo[]. this.resolveRestParameterType(newTag, fnDecl, paramNode); } for (const { tagName, parameterName, text } of tags) { if (tagName === 'param' && parameterName === newTag.parameterName) { newTag.text = text; break; } } if (!isThisParam) { const paramIdx = hasThisParam ? i - 1 : i; if (!paramTags[paramIdx]) paramTags.push([]); paramTags[paramIdx].push(newTag); } else { thisTags.push(newTag); } } argCounts.push(hasThisParam ? sig.declaration.parameters.length - 1 : sig.declaration.parameters.length); // Return type. if (!isConstructor) { const returnTag = { tagName: 'return', }; const retType = typeChecker.getReturnTypeOfSignature(sig); // Generate a templated `@this` tag for TypeScript `foo(): this` return type specification. // Make sure not to do that if the function already has used `@this` due to a this // parameter. It's not clear how to resolve the two conflicting this types best, the current // solution prefers the explicitly given `this` parameter. // tslint:disable-next-line:no-any accessing TS internal field. if (retType['isThisType'] && !hasThisParam) { // foo(): this thisReturnType = retType; addTag({ tagName: 'template', text: 'THIS' }); addTag({ tagName: 'this', type: 'THIS' }); returnTag.type = 'THIS'; } else { returnTag.type = this.typeToClosure(fnDecl, retType); for (const { tagName, text } of tags) { if (tagName === 'return') { returnTag.text = text; break; } } } returnTags.push(returnTag); } } if (typeParameterNames.size > 0) { addTag({ tagName: 'template', text: Array.from(typeParameterNames.values()).join(', ') }); } const newDoc = Array.from(tagsByName.values()); for (const extraTag of extraTags) { if (extraTag.tagName === 'implements') newDoc.push(extraTag); } if (thisTags.length > 0) { newDoc.push(jsdoc.merge(thisTags)); } const minArgsCount = Math.min(...argCounts); const maxArgsCount = Math.max(...argCounts); // Merge the JSDoc tags for each overloaded parameter. // Ensure each parameter has a unique name; the merging process can otherwise // accidentally generate the same parameter name twice. const paramNames = new Set(); let foundOptional = false; for (let i = 0; i < maxArgsCount; i++) { const paramTag = jsdoc.merge(paramTags[i]); if (paramTag.parameterName) { if (paramNames.has(paramTag.parameterName)) { paramTag.parameterName += i.toString(); } paramNames.add(paramTag.parameterName); } // If the tag is optional, mark parameters following optional as optional, // even if they are not, since Closure restricts this, see // https://github.com/google/closure-compiler/issues/2314 if (!paramTag.restParam && (paramTag.optional || foundOptional || i >= minArgsCount)) { foundOptional = true; paramTag.optional = true; } newDoc.push(paramTag); if (paramTag.restParam) { // Cannot have any parameters after a rest param. // Just dump the remaining parameters. break; } } // Merge the JSDoc tags for each overloaded return. if (!isConstructor) { newDoc.push(jsdoc.merge(returnTags)); } return { tags: newDoc, parameterNames: newDoc.filter(t => t.tagName === 'param').map(t => t.parameterName), thisReturnType, }; } } exports.ModuleTypeTranslator = ModuleTypeTranslator; //# sourceMappingURL=module_type_translator.js.map