/** * @license * Copyright (c) 2017 The Polymer Project Authors. All rights reserved. * This code may only be used under the BSD style license found at * http://polymer.github.io/LICENSE.txt The complete set of authors may be found * at http://polymer.github.io/AUTHORS.txt The complete set of contributors may * be found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by * Google as part of the polymer project is also subject to an additional IP * rights grant found at http://polymer.github.io/PATENTS.txt */ import * as babel from '@babel/types'; import * as jsdoc from 'doctrine'; import * as fsExtra from 'fs-extra'; import * as minimatch from 'minimatch'; import * as path from 'path'; import * as analyzer from 'polymer-analyzer'; import {Function as AnalyzerFunction} from 'polymer-analyzer/lib/javascript/function'; import Uri from 'vscode-uri'; import {closureParamToTypeScript, closureTypeToTypeScript} from './closure-types'; import {isEsModuleDocument, resolveImportExportFeature} from './es-modules'; import * as ts from './ts-ast'; /** * Configuration for declaration generation. */ export interface Config { /** * Skip source files whose paths match any of these glob patterns. If * undefined, defaults to excluding "index.html" and directories ending in * "test" or "demo". */ excludeFiles?: string[]; /** * The same as `excludeFiles`, for backwards compatibility. Will be removed in * next major version. */ exclude?: string[]; /** * Do not emit any declarations for features that have any of these * identifiers. */ excludeIdentifiers?: string[]; /** * Remove any triple-slash references to these files, specified as paths * relative to the analysis root directory. */ removeReferences?: string[]; /** * Additional files to insert as triple-slash reference statements. Given the * map `a: b[]`, a will get an additional reference statement for each file * path in b. All paths are relative to the analysis root directory. */ addReferences?: {[filepath: string]: string[]}; /** * Whenever a type with a name in this map is encountered, replace it with * the given name. Note this only applies to named types found in places like * function/method parameters and return types. It does not currently rename * e.g. entire generated classes. */ renameTypes?: {[name: string]: string}; /** * A map from an ES module path (relative to the analysis root directory) to * an array of identifiers exported by that module. If any of those * identifiers are encountered in a generated typings file, an import for that * identifier from the specified module will be inserted into the typings * file. */ autoImport?: {[modulePath: string]: string[]}; /** * If true, outputs declarations in 'goog:' modules instead of using * simple ES modules. This is a temporary hack to account for how modules are * resolved for TypeScript inside google3. This is probably not at all useful * for anyone but the Polymer team. */ googModules?: boolean; /** * If true, does not log warnings detected when analyzing code, * only diagnostics of Error severity. */ hideWarnings?: boolean; } const defaultExclude = [ 'index.html', 'test/**', 'demo/**', ]; /** * Analyze all files in the given directory using Polymer Analyzer, and return * TypeScript declaration document strings in a map keyed by relative path. */ export async function generateDeclarations( rootDir: string, config: Config): Promise> { // Note that many Bower projects also have a node_modules/, but the reverse is // unlikely. const isBowerProject = await fsExtra.pathExists(path.join(rootDir, 'bower_components')) === true; const a = new analyzer.Analyzer({ urlLoader: new analyzer.FsUrlLoader(rootDir), urlResolver: new analyzer.PackageUrlResolver({ packageDir: rootDir, componentDir: isBowerProject ? 'bower_components/' : 'node_modules/', }), moduleResolution: isBowerProject ? undefined : 'node', }); const analysis = await a.analyzePackage(); const outFiles = new Map(); for (const tsDoc of await analyzerToAst(analysis, config, rootDir)) { outFiles.set(tsDoc.path, tsDoc.serialize()); } return outFiles; } /** * Make TypeScript declaration documents from the given Polymer Analyzer * result. */ async function analyzerToAst( analysis: analyzer.Analysis, config: Config, rootDir: string): Promise { const excludeFiles = (config.excludeFiles || config.exclude || defaultExclude) .map((p) => new minimatch.Minimatch(p)); const addReferences = config.addReferences || {}; const removeReferencesResolved = new Set( (config.removeReferences || []).map((r) => path.resolve(rootDir, r))); const renameTypes = new Map(Object.entries(config.renameTypes || {})); // Map from identifier to the module path that exports it. const autoImportMap = new Map(); if (config.autoImport !== undefined) { for (const importPath in config.autoImport) { for (const identifier of config.autoImport[importPath]) { autoImportMap.set(identifier, importPath); } } } const analyzerDocs = [ ...analysis.getFeatures({kind: 'html-document'}), ...analysis.getFeatures({kind: 'js-document'}), ]; // We want to produce one declarations file for each file basename. There // might be both `foo.html` and `foo.js`, and we want their declarations to be // combined into a signal `foo.d.ts`. So we first group Analyzer documents by // their declarations filename. const declarationDocs = new Map(); for (const jsDoc of analyzerDocs) { // For every HTML or JS file, Analyzer is going to give us 1) the top-level // document, and 2) N inline documents for any nested content (e.g. script // tags in HTML). The top-level document will give us all the nested // features we need, so skip any inline ones. if (jsDoc.isInline) { continue; } const sourcePath = analyzerUrlToRelativePath(jsDoc.url, rootDir); if (sourcePath === undefined) { console.warn( `Skipping source document without local file URL: ${jsDoc.url}`); continue; } if (excludeFiles.some((r) => r.match(sourcePath))) { continue; } const filename = makeDeclarationsFilename(sourcePath); let docs = declarationDocs.get(filename); if (!docs) { docs = []; declarationDocs.set(filename, docs); } docs.push(jsDoc); } const tsDocs = []; const warnings = [...analysis.getWarnings()]; for (const [declarationsFilename, analyzerDocs] of declarationDocs) { const tsDoc = new ts.Document({ path: declarationsFilename, header: makeHeader( analyzerDocs.map((d) => analyzerUrlToRelativePath(d.url, rootDir)) .filter((url): url is string => url !== undefined)), tsLintDisables: [{ ruleName: 'variable-name', why: `Describing an API that's defined elsewhere.`, }], }); for (const analyzerDoc of analyzerDocs) { if (isEsModuleDocument(analyzerDoc)) { tsDoc.isEsModule = true; } } for (const analyzerDoc of analyzerDocs) { const generator = new TypeGenerator( tsDoc, analysis, analyzerDoc, rootDir, config.excludeIdentifiers || []); generator.handleDocument(); warnings.push(...generator.warnings); } for (const ref of tsDoc.referencePaths) { const resolvedRef = path.resolve(rootDir, path.dirname(tsDoc.path), ref); if (removeReferencesResolved.has(resolvedRef)) { tsDoc.referencePaths.delete(ref); } } for (const ref of addReferences[tsDoc.path] || []) { tsDoc.referencePaths.add(path.relative(path.dirname(tsDoc.path), ref)); } for (const node of tsDoc.traverse()) { if (node.kind === 'name') { const renamed = renameTypes.get(node.name); if (renamed !== undefined) { node.name = renamed; } } } addAutoImports(tsDoc, autoImportMap); tsDoc.simplify(); // Include even documents with no members. They might be dependencies of // other files via the HTML import graph, and it's simpler to have empty // files than to try and prune the references (especially across packages). tsDocs.push(tsDoc); } const filteredWarnings = warnings.filter((warning) => { if (config.hideWarnings && warning.severity !== analyzer.Severity.ERROR) { return false; } const sourcePath = analyzerUrlToRelativePath(warning.sourceRange.file, rootDir); return sourcePath !== undefined && !excludeFiles.some((pattern) => pattern.match(sourcePath)); }); const warningPrinter = new analyzer.WarningPrinter(process.stderr, {maxCodeLines: 1}); await warningPrinter.printWarnings(filteredWarnings); if (filteredWarnings.some( (warning) => warning.severity === analyzer.Severity.ERROR)) { throw new Error('Encountered error generating types.'); } if (config.googModules) { return tsDocs.map((d) => transformToGoogStyle(d, rootDir)); } return tsDocs; } /** * Insert imports into the typings for any referenced identifiers listed in the * autoImport configuration, unless they are already imported. */ function addAutoImports(tsDoc: ts.Document, autoImport: Map) { const alreadyImported = getImportedIdentifiers(tsDoc); for (const node of tsDoc.traverse()) { if (node.kind === 'name') { let importSpecifier = autoImport.get(node.name); if (importSpecifier === undefined) { continue; } if (alreadyImported.has(node.name)) { continue; } if (importSpecifier.startsWith('.')) { if (makeDeclarationsFilename(importSpecifier) === tsDoc.path) { // Don't import from yourself. continue; } importSpecifier = path.relative(path.dirname(tsDoc.path), importSpecifier); if (!importSpecifier.startsWith('.')) { importSpecifier = './' + importSpecifier; } } tsDoc.members.push(new ts.Import({ identifiers: [{identifier: node.name}], fromModuleSpecifier: importSpecifier, })); alreadyImported.add(node.name); } } } function getPackageName(rootDir: string) { let packageInfo: {name?: string}; try { packageInfo = JSON.parse( fsExtra.readFileSync(path.join(rootDir, 'package.json'), 'utf-8')); } catch { return undefined; } return packageInfo.name; } function googModuleForNameBasedImportSpecifier(spec: string) { const name = // remove trailing .d.ts and .js spec.replace(/(\.d\.ts|\.js)$/, '') // foo-bar.dom becomes fooBarDom .replace(/[-\.](\w)/g, (_, s) => s.toUpperCase()) // remove leading @ .replace(/^@/g, '') // slash separated paths becomes dot separated namespace .replace(/\//g, '.'); // add goog: at the beginning return `goog:${name}`; } /* Note: this function modifies tsDoc. */ function transformToGoogStyle(tsDoc: ts.Document, rootDir: string) { const packageName = getPackageName(rootDir); if (!tsDoc.isEsModule || !packageName) { return tsDoc; } for (const child of tsDoc.traverse()) { if (child.kind === 'import' || child.kind === 'export') { if (!child.fromModuleSpecifier) { continue; } let spec = child.fromModuleSpecifier; if (spec.startsWith('.')) { spec = path.join( packageName, path.relative( rootDir, path.join(rootDir, path.dirname(tsDoc.path), spec)) .replace(/^\.\//, '')); } const elementName = spec.split('/')[1]; let trailingComment: undefined|string = undefined; if (elementName && !/\./.test(elementName)) { trailingComment = ` // from //third_party/javascript/polymer/v2/${elementName}`; } const googSpecifier = googModuleForNameBasedImportSpecifier(spec); if (googSpecifier !== undefined) { child.fromModuleSpecifier = googSpecifier; child.trailingComment = trailingComment; } } } let googModuleName = googModuleForNameBasedImportSpecifier(path.join(packageName, tsDoc.path)); if (googModuleName === undefined) { googModuleName = tsDoc.path; } return new ts.Document({ path: tsDoc.path, header: tsDoc.header, referencePaths: tsDoc.referencePaths, tsLintDisables: tsDoc.tsLintDisables, isEsModule: false, members: [new ts.Namespace( {name: googModuleName, members: tsDoc.members, style: 'module'})] }); } /** * Return all local identifiers imported by the given typings. */ function getImportedIdentifiers(tsDoc: ts.Document): Set { const identifiers = new Set(); for (const member of tsDoc.members) { if (member.kind === 'import') { for (const {identifier, alias} of member.identifiers) { if (identifier !== ts.AllIdentifiers) { identifiers.add(alias || identifier); } } } } return identifiers; } /** * Analyzer always returns fully specified URLs with a protocol and an absolute * path (e.g. "file:/foo/bar"). Return just the file path, relative to our * project root. */ function analyzerUrlToRelativePath( analyzerUrl: string, rootDir: string): string|undefined { const parsed = Uri.parse(analyzerUrl); if (parsed.scheme !== 'file' || parsed.authority || !parsed.fsPath) { return undefined; } return path.relative(rootDir, parsed.fsPath); } /** * Create a TypeScript declarations filename for the given source document URL. * Simply replaces the file extension with `d.ts`. */ function makeDeclarationsFilename(sourceUrl: string): string { const parsed = path.parse(sourceUrl); return path.join(parsed.dir, parsed.name) + '.d.ts'; } /** * Generate the header comment to show at the top of a declarations document. */ function makeHeader(sourceUrls: string[]): string { return `DO NOT EDIT This file was automatically generated by https://github.com/Polymer/tools/tree/master/packages/gen-typescript-declarations To modify these typings, edit the source file(s): ${sourceUrls.map((url) => ' ' + url).join('\n')}`; } class TypeGenerator { public warnings: analyzer.Warning[] = []; private excludeIdentifiers: Set; /** * Identifiers in this set will always be considered resolvable, e.g. * for when determining what identifiers should be exported. */ private forceResolvable = new Set(); constructor( private root: ts.Document, private analysis: analyzer.Analysis, private analyzerDoc: analyzer.Document, private rootDir: string, excludeIdentifiers: string[]) { this.excludeIdentifiers = new Set(excludeIdentifiers); } private warn(feature: analyzer.Feature, message: string) { this.warnings.push(new analyzer.Warning({ message, sourceRange: feature.sourceRange!, severity: analyzer.Severity.WARNING, // We don't really need specific codes. code: 'GEN_TYPESCRIPT_DECLARATIONS_WARNING', parsedDocument: this.analyzerDoc.parsedDocument, })); } /** * Extend the given TypeScript declarations document with all of the relevant * items in the given Polymer Analyzer document. */ handleDocument() { for (const feature of this.analyzerDoc.getFeatures()) { if ([...feature.identifiers].some( (id) => this.excludeIdentifiers.has(id))) { continue; } if (isPrivate(feature)) { continue; } if (feature.kinds.has('element')) { this.handleElement(feature as analyzer.Element); } else if (feature.kinds.has('behavior')) { this.handleBehavior(feature as analyzer.PolymerBehavior); } else if (feature.kinds.has('element-mixin')) { this.handleMixin(feature as analyzer.ElementMixin); } else if (feature.kinds.has('class')) { this.handleClass(feature as analyzer.Class); } else if (feature.kinds.has('function')) { this.handleFunction(feature as AnalyzerFunction); } else if (feature.kinds.has('namespace')) { this.handleNamespace(feature as analyzer.Namespace); } else if (feature.kinds.has('html-import')) { // Sometimes an Analyzer document includes an import feature that is // inbound (things that depend on me) instead of outbound (things I // depend on). For example, if an HTML file has a