UNPKG

43.1 kBPlain TextView Raw
1/**
2 * @license
3 * Copyright (c) 2017 The Polymer Project Authors. All rights reserved.
4 * This code may only be used under the BSD style license found at
5 * http://polymer.github.io/LICENSE.txt The complete set of authors may be found
6 * at http://polymer.github.io/AUTHORS.txt The complete set of contributors may
7 * be found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by
8 * Google as part of the polymer project is also subject to an additional IP
9 * rights grant found at http://polymer.github.io/PATENTS.txt
10 */
11
12import * as babel from '@babel/types';
13import * as jsdoc from 'doctrine';
14import * as fsExtra from 'fs-extra';
15import * as minimatch from 'minimatch';
16import * as path from 'path';
17import * as analyzer from 'polymer-analyzer';
18import {Function as AnalyzerFunction} from 'polymer-analyzer/lib/javascript/function';
19import Uri from 'vscode-uri';
20
21import {closureParamToTypeScript, closureTypeToTypeScript} from './closure-types';
22import {isEsModuleDocument, resolveImportExportFeature} from './es-modules';
23import * as ts from './ts-ast';
24
25/**
26 * Configuration for declaration generation.
27 */
28export interface Config {
29 /**
30 * Skip source files whose paths match any of these glob patterns. If
31 * undefined, defaults to excluding "index.html" and directories ending in
32 * "test" or "demo".
33 */
34 excludeFiles?: string[];
35
36 /**
37 * The same as `excludeFiles`, for backwards compatibility. Will be removed in
38 * next major version.
39 */
40 exclude?: string[];
41
42 /**
43 * Do not emit any declarations for features that have any of these
44 * identifiers.
45 */
46 excludeIdentifiers?: string[];
47
48 /**
49 * Remove any triple-slash references to these files, specified as paths
50 * relative to the analysis root directory.
51 */
52 removeReferences?: string[];
53
54 /**
55 * Additional files to insert as triple-slash reference statements. Given the
56 * map `a: b[]`, a will get an additional reference statement for each file
57 * path in b. All paths are relative to the analysis root directory.
58 */
59 addReferences?: {[filepath: string]: string[]};
60
61 /**
62 * Whenever a type with a name in this map is encountered, replace it with
63 * the given name. Note this only applies to named types found in places like
64 * function/method parameters and return types. It does not currently rename
65 * e.g. entire generated classes.
66 */
67 renameTypes?: {[name: string]: string};
68
69 /**
70 * A map from an ES module path (relative to the analysis root directory) to
71 * an array of identifiers exported by that module. If any of those
72 * identifiers are encountered in a generated typings file, an import for that
73 * identifier from the specified module will be inserted into the typings
74 * file.
75 */
76 autoImport?: {[modulePath: string]: string[]};
77
78 /**
79 * If true, outputs declarations in 'goog:' modules instead of using
80 * simple ES modules. This is a temporary hack to account for how modules are
81 * resolved for TypeScript inside google3. This is probably not at all useful
82 * for anyone but the Polymer team.
83 */
84 googModules?: boolean;
85
86 /**
87 * If true, does not log warnings detected when analyzing code,
88 * only diagnostics of Error severity.
89 */
90 hideWarnings?: boolean;
91}
92
93const defaultExclude = [
94 'index.html',
95 'test/**',
96 'demo/**',
97];
98
99/**
100 * Analyze all files in the given directory using Polymer Analyzer, and return
101 * TypeScript declaration document strings in a map keyed by relative path.
102 */
103export async function generateDeclarations(
104 rootDir: string, config: Config): Promise<Map<string, string>> {
105 // Note that many Bower projects also have a node_modules/, but the reverse is
106 // unlikely.
107 const isBowerProject =
108 await fsExtra.pathExists(path.join(rootDir, 'bower_components')) === true;
109 const a = new analyzer.Analyzer({
110 urlLoader: new analyzer.FsUrlLoader(rootDir),
111 urlResolver: new analyzer.PackageUrlResolver({
112 packageDir: rootDir,
113 componentDir: isBowerProject ? 'bower_components/' : 'node_modules/',
114 }),
115 moduleResolution: isBowerProject ? undefined : 'node',
116 });
117 const analysis = await a.analyzePackage();
118 const outFiles = new Map<string, string>();
119 for (const tsDoc of await analyzerToAst(analysis, config, rootDir)) {
120 outFiles.set(tsDoc.path, tsDoc.serialize());
121 }
122 return outFiles;
123}
124
125/**
126 * Make TypeScript declaration documents from the given Polymer Analyzer
127 * result.
128 */
129async function analyzerToAst(
130 analysis: analyzer.Analysis, config: Config, rootDir: string):
131 Promise<ts.Document[]> {
132 const excludeFiles = (config.excludeFiles || config.exclude || defaultExclude)
133 .map((p) => new minimatch.Minimatch(p));
134 const addReferences = config.addReferences || {};
135 const removeReferencesResolved = new Set(
136 (config.removeReferences || []).map((r) => path.resolve(rootDir, r)));
137 const renameTypes = new Map(Object.entries(config.renameTypes || {}));
138
139 // Map from identifier to the module path that exports it.
140 const autoImportMap = new Map<string, string>();
141 if (config.autoImport !== undefined) {
142 for (const importPath in config.autoImport) {
143 for (const identifier of config.autoImport[importPath]) {
144 autoImportMap.set(identifier, importPath);
145 }
146 }
147 }
148
149 const analyzerDocs = [
150 ...analysis.getFeatures({kind: 'html-document'}),
151 ...analysis.getFeatures({kind: 'js-document'}),
152 ];
153
154 // We want to produce one declarations file for each file basename. There
155 // might be both `foo.html` and `foo.js`, and we want their declarations to be
156 // combined into a signal `foo.d.ts`. So we first group Analyzer documents by
157 // their declarations filename.
158 const declarationDocs = new Map<string, analyzer.Document[]>();
159 for (const jsDoc of analyzerDocs) {
160 // For every HTML or JS file, Analyzer is going to give us 1) the top-level
161 // document, and 2) N inline documents for any nested content (e.g. script
162 // tags in HTML). The top-level document will give us all the nested
163 // features we need, so skip any inline ones.
164 if (jsDoc.isInline) {
165 continue;
166 }
167 const sourcePath = analyzerUrlToRelativePath(jsDoc.url, rootDir);
168 if (sourcePath === undefined) {
169 console.warn(
170 `Skipping source document without local file URL: ${jsDoc.url}`);
171 continue;
172 }
173 if (excludeFiles.some((r) => r.match(sourcePath))) {
174 continue;
175 }
176 const filename = makeDeclarationsFilename(sourcePath);
177 let docs = declarationDocs.get(filename);
178 if (!docs) {
179 docs = [];
180 declarationDocs.set(filename, docs);
181 }
182 docs.push(jsDoc);
183 }
184
185 const tsDocs = [];
186 const warnings = [...analysis.getWarnings()];
187 for (const [declarationsFilename, analyzerDocs] of declarationDocs) {
188 const tsDoc = new ts.Document({
189 path: declarationsFilename,
190 header: makeHeader(
191 analyzerDocs.map((d) => analyzerUrlToRelativePath(d.url, rootDir))
192 .filter((url): url is string => url !== undefined)),
193 tsLintDisables: [{
194 ruleName: 'variable-name',
195 why: `Describing an API that's defined elsewhere.`,
196 }],
197 });
198 for (const analyzerDoc of analyzerDocs) {
199 if (isEsModuleDocument(analyzerDoc)) {
200 tsDoc.isEsModule = true;
201 }
202 }
203
204 for (const analyzerDoc of analyzerDocs) {
205 const generator = new TypeGenerator(
206 tsDoc,
207 analysis,
208 analyzerDoc,
209 rootDir,
210 config.excludeIdentifiers || []);
211 generator.handleDocument();
212 warnings.push(...generator.warnings);
213 }
214
215 for (const ref of tsDoc.referencePaths) {
216 const resolvedRef = path.resolve(rootDir, path.dirname(tsDoc.path), ref);
217 if (removeReferencesResolved.has(resolvedRef)) {
218 tsDoc.referencePaths.delete(ref);
219 }
220 }
221 for (const ref of addReferences[tsDoc.path] || []) {
222 tsDoc.referencePaths.add(path.relative(path.dirname(tsDoc.path), ref));
223 }
224 for (const node of tsDoc.traverse()) {
225 if (node.kind === 'name') {
226 const renamed = renameTypes.get(node.name);
227 if (renamed !== undefined) {
228 node.name = renamed;
229 }
230 }
231 }
232 addAutoImports(tsDoc, autoImportMap);
233 tsDoc.simplify();
234
235 // Include even documents with no members. They might be dependencies of
236 // other files via the HTML import graph, and it's simpler to have empty
237 // files than to try and prune the references (especially across packages).
238 tsDocs.push(tsDoc);
239 }
240
241 const filteredWarnings = warnings.filter((warning) => {
242 if (config.hideWarnings && warning.severity !== analyzer.Severity.ERROR) {
243 return false;
244 }
245 const sourcePath =
246 analyzerUrlToRelativePath(warning.sourceRange.file, rootDir);
247 return sourcePath !== undefined &&
248 !excludeFiles.some((pattern) => pattern.match(sourcePath));
249 });
250 const warningPrinter =
251 new analyzer.WarningPrinter(process.stderr, {maxCodeLines: 1});
252 await warningPrinter.printWarnings(filteredWarnings);
253 if (filteredWarnings.some(
254 (warning) => warning.severity === analyzer.Severity.ERROR)) {
255 throw new Error('Encountered error generating types.');
256 }
257
258 if (config.googModules) {
259 return tsDocs.map((d) => transformToGoogStyle(d, rootDir));
260 }
261 return tsDocs;
262}
263
264/**
265 * Insert imports into the typings for any referenced identifiers listed in the
266 * autoImport configuration, unless they are already imported.
267 */
268function addAutoImports(tsDoc: ts.Document, autoImport: Map<string, string>) {
269 const alreadyImported = getImportedIdentifiers(tsDoc);
270
271 for (const node of tsDoc.traverse()) {
272 if (node.kind === 'name') {
273 let importSpecifier = autoImport.get(node.name);
274 if (importSpecifier === undefined) {
275 continue;
276 }
277 if (alreadyImported.has(node.name)) {
278 continue;
279 }
280 if (importSpecifier.startsWith('.')) {
281 if (makeDeclarationsFilename(importSpecifier) === tsDoc.path) {
282 // Don't import from yourself.
283 continue;
284 }
285 importSpecifier =
286 path.relative(path.dirname(tsDoc.path), importSpecifier);
287 if (!importSpecifier.startsWith('.')) {
288 importSpecifier = './' + importSpecifier;
289 }
290 }
291 tsDoc.members.push(new ts.Import({
292 identifiers: [{identifier: node.name}],
293 fromModuleSpecifier: importSpecifier,
294 }));
295 alreadyImported.add(node.name);
296 }
297 }
298}
299
300function getPackageName(rootDir: string) {
301 let packageInfo: {name?: string};
302 try {
303 packageInfo = JSON.parse(
304 fsExtra.readFileSync(path.join(rootDir, 'package.json'), 'utf-8'));
305 } catch {
306 return undefined;
307 }
308 return packageInfo.name;
309}
310
311function googModuleForNameBasedImportSpecifier(spec: string) {
312 const name =
313 // remove trailing .d.ts and .js
314 spec.replace(/(\.d\.ts|\.js)$/, '')
315 // foo-bar.dom becomes fooBarDom
316 .replace(/[-\.](\w)/g, (_, s) => s.toUpperCase())
317 // remove leading @
318 .replace(/^@/g, '')
319 // slash separated paths becomes dot separated namespace
320 .replace(/\//g, '.');
321
322 // add goog: at the beginning
323 return `goog:${name}`;
324}
325
326/* Note: this function modifies tsDoc. */
327function transformToGoogStyle(tsDoc: ts.Document, rootDir: string) {
328 const packageName = getPackageName(rootDir);
329 if (!tsDoc.isEsModule || !packageName) {
330 return tsDoc;
331 }
332
333 for (const child of tsDoc.traverse()) {
334 if (child.kind === 'import' || child.kind === 'export') {
335 if (!child.fromModuleSpecifier) {
336 continue;
337 }
338 let spec = child.fromModuleSpecifier;
339 if (spec.startsWith('.')) {
340 spec = path.join(
341 packageName,
342 path.relative(
343 rootDir, path.join(rootDir, path.dirname(tsDoc.path), spec))
344 .replace(/^\.\//, ''));
345 }
346 const elementName = spec.split('/')[1];
347 let trailingComment: undefined|string = undefined;
348 if (elementName && !/\./.test(elementName)) {
349 trailingComment =
350 ` // from //third_party/javascript/polymer/v2/${elementName}`;
351 }
352 const googSpecifier = googModuleForNameBasedImportSpecifier(spec);
353 if (googSpecifier !== undefined) {
354 child.fromModuleSpecifier = googSpecifier;
355 child.trailingComment = trailingComment;
356 }
357 }
358 }
359
360 let googModuleName =
361 googModuleForNameBasedImportSpecifier(path.join(packageName, tsDoc.path));
362 if (googModuleName === undefined) {
363 googModuleName = tsDoc.path;
364 }
365 return new ts.Document({
366 path: tsDoc.path,
367 header: tsDoc.header,
368 referencePaths: tsDoc.referencePaths,
369 tsLintDisables: tsDoc.tsLintDisables,
370 isEsModule: false,
371 members: [new ts.Namespace(
372 {name: googModuleName, members: tsDoc.members, style: 'module'})]
373 });
374}
375
376/**
377 * Return all local identifiers imported by the given typings.
378 */
379function getImportedIdentifiers(tsDoc: ts.Document): Set<string> {
380 const identifiers = new Set<string>();
381 for (const member of tsDoc.members) {
382 if (member.kind === 'import') {
383 for (const {identifier, alias} of member.identifiers) {
384 if (identifier !== ts.AllIdentifiers) {
385 identifiers.add(alias || identifier);
386 }
387 }
388 }
389 }
390 return identifiers;
391}
392
393/**
394 * Analyzer always returns fully specified URLs with a protocol and an absolute
395 * path (e.g. "file:/foo/bar"). Return just the file path, relative to our
396 * project root.
397 */
398function analyzerUrlToRelativePath(
399 analyzerUrl: string, rootDir: string): string|undefined {
400 const parsed = Uri.parse(analyzerUrl);
401 if (parsed.scheme !== 'file' || parsed.authority || !parsed.fsPath) {
402 return undefined;
403 }
404 return path.relative(rootDir, parsed.fsPath);
405}
406
407/**
408 * Create a TypeScript declarations filename for the given source document URL.
409 * Simply replaces the file extension with `d.ts`.
410 */
411function makeDeclarationsFilename(sourceUrl: string): string {
412 const parsed = path.parse(sourceUrl);
413 return path.join(parsed.dir, parsed.name) + '.d.ts';
414}
415
416/**
417 * Generate the header comment to show at the top of a declarations document.
418 */
419function makeHeader(sourceUrls: string[]): string {
420 return `DO NOT EDIT
421
422This file was automatically generated by
423 https://github.com/Polymer/tools/tree/master/packages/gen-typescript-declarations
424
425To modify these typings, edit the source file(s):
426${sourceUrls.map((url) => ' ' + url).join('\n')}`;
427}
428
429class TypeGenerator {
430 public warnings: analyzer.Warning[] = [];
431 private excludeIdentifiers: Set<String>;
432
433 /**
434 * Identifiers in this set will always be considered resolvable, e.g.
435 * for when determining what identifiers should be exported.
436 */
437 private forceResolvable = new Set<string>();
438
439 constructor(
440 private root: ts.Document, private analysis: analyzer.Analysis,
441 private analyzerDoc: analyzer.Document, private rootDir: string,
442 excludeIdentifiers: string[]) {
443 this.excludeIdentifiers = new Set(excludeIdentifiers);
444 }
445
446 private warn(feature: analyzer.Feature, message: string) {
447 this.warnings.push(new analyzer.Warning({
448 message,
449 sourceRange: feature.sourceRange!,
450 severity: analyzer.Severity.WARNING,
451 // We don't really need specific codes.
452 code: 'GEN_TYPESCRIPT_DECLARATIONS_WARNING',
453 parsedDocument: this.analyzerDoc.parsedDocument,
454 }));
455 }
456
457 /**
458 * Extend the given TypeScript declarations document with all of the relevant
459 * items in the given Polymer Analyzer document.
460 */
461 handleDocument() {
462 for (const feature of this.analyzerDoc.getFeatures()) {
463 if ([...feature.identifiers].some(
464 (id) => this.excludeIdentifiers.has(id))) {
465 continue;
466 }
467 if (isPrivate(feature)) {
468 continue;
469 }
470 if (feature.kinds.has('element')) {
471 this.handleElement(feature as analyzer.Element);
472 } else if (feature.kinds.has('behavior')) {
473 this.handleBehavior(feature as analyzer.PolymerBehavior);
474 } else if (feature.kinds.has('element-mixin')) {
475 this.handleMixin(feature as analyzer.ElementMixin);
476 } else if (feature.kinds.has('class')) {
477 this.handleClass(feature as analyzer.Class);
478 } else if (feature.kinds.has('function')) {
479 this.handleFunction(feature as AnalyzerFunction);
480 } else if (feature.kinds.has('namespace')) {
481 this.handleNamespace(feature as analyzer.Namespace);
482 } else if (feature.kinds.has('html-import')) {
483 // Sometimes an Analyzer document includes an import feature that is
484 // inbound (things that depend on me) instead of outbound (things I
485 // depend on). For example, if an HTML file has a <script> tag for a JS
486 // file, then the JS file's Analyzer document will include that <script>
487 // tag as an import feature. We only care about outbound dependencies,
488 // hence this check.
489 if (feature.sourceRange &&
490 feature.sourceRange.file === this.analyzerDoc.url) {
491 this.handleHtmlImport(feature as analyzer.Import);
492 }
493 } else if (feature.kinds.has('js-import')) {
494 this.handleJsImport(feature as analyzer.JavascriptImport);
495 } else if (feature.kinds.has('export')) {
496 this.handleJsExport(feature as analyzer.Export);
497 }
498 }
499 }
500
501 /**
502 * Add the given Element to the given TypeScript declarations document.
503 */
504 private handleElement(feature: analyzer.Element) {
505 // Whether this element has a constructor that is assigned and can be
506 // called. If it does we'll emit a class, otherwise an interface.
507 let constructable;
508
509 let fullName; // Fully qualified reference, e.g. `Polymer.DomModule`.
510 let shortName; // Just the last part of the name, e.g. `DomModule`.
511 let parent; // Where in the namespace tree does this live.
512
513 if (feature.className) {
514 constructable = true;
515 let namespacePath;
516 [namespacePath, shortName] = splitReference(feature.className);
517 fullName = feature.className;
518 parent = findOrCreateNamespace(this.root, namespacePath);
519
520 } else if (feature.tagName) {
521 // No `className` means this is an element defined by a call to the
522 // Polymer function without a LHS assignment. We'll follow the convention
523 // of the Closure Polymer Pass, and emit a global namespace interface
524 // called `FooBarElement` (given a `tagName` of `foo-bar`). More context
525 // here:
526 //
527 // https://github.com/google/closure-compiler/wiki/Polymer-Pass#element-type-names-for-1xhybrid-call-syntax
528 // https://github.com/google/closure-compiler/blob/master/src/com/google/javascript/jscomp/PolymerClassDefinition.java#L128
529 constructable = false;
530 shortName = kebabToCamel(feature.tagName) + 'Element';
531 fullName = shortName;
532 parent = this.root;
533
534 } else {
535 this.warn(feature, `Could not find element name.`);
536 return;
537 }
538
539 const legacyPolymerInterfaces = [];
540 if (isPolymerElement(feature)) {
541 legacyPolymerInterfaces.push(...feature.behaviorAssignments.map(
542 (behavior) => behavior.identifier));
543
544 if (feature.isLegacyFactoryCall) {
545 if (this.root.isEsModule) {
546 legacyPolymerInterfaces.push('LegacyElementMixin');
547 if (!getImportedIdentifiers(this.root).has('LegacyElementMixin')) {
548 this.root.members.push(new ts.Import({
549 identifiers: [{identifier: 'LegacyElementMixin'}],
550 fromModuleSpecifier:
551 '@polymer/polymer/lib/legacy/legacy-element-mixin.js',
552 }));
553 }
554
555 } else {
556 legacyPolymerInterfaces.push('Polymer.LegacyElementMixin');
557 }
558
559 legacyPolymerInterfaces.push('HTMLElement');
560 }
561 }
562
563 if (constructable) {
564 this.handleClass(feature);
565
566 if (legacyPolymerInterfaces.length > 0) {
567 // Augment the class interface.
568 parent.members.push(new ts.Interface({
569 name: shortName,
570 extends: legacyPolymerInterfaces,
571 }));
572 }
573
574 } else {
575 parent.members.push(new ts.Interface({
576 name: shortName,
577 description: feature.description || feature.summary,
578 properties: this.handleProperties(feature.properties.values()),
579 // Don't worry about about static methods when we're not
580 // constructable. Since there's no handle to the constructor, they
581 // could never be called.
582 methods: this.handleMethods(feature.methods.values()),
583 extends: [
584 ...feature.mixins.map((mixin) => mixin.identifier),
585 ...legacyPolymerInterfaces,
586 ],
587 }));
588
589 if (isPolymerElement(feature) && feature.isLegacyFactoryCall &&
590 this.root.isEsModule) {
591 this.root.members.push(
592 new ts.Export({identifiers: [{identifier: shortName}]}));
593 }
594 }
595
596 // The `HTMLElementTagNameMap` global interface maps custom element tag
597 // names to their definitions, so that TypeScript knows that e.g.
598 // `dom.createElement('my-foo')` returns a `MyFoo`. Augment the map with
599 // this custom element.
600 if (feature.tagName) {
601 const elementMap = findOrCreateInterface(
602 this.root.isEsModule ? findOrCreateGlobalNamespace(this.root) :
603 this.root,
604 'HTMLElementTagNameMap');
605 elementMap.properties.push(new ts.Property({
606 name: feature.tagName,
607 type: new ts.NameType(fullName),
608 }));
609 }
610 }
611
612 /**
613 * Add the given Polymer Behavior to the given TypeScript declarations
614 * document.
615 */
616 private handleBehavior(feature: analyzer.PolymerBehavior) {
617 if (!feature.className) {
618 this.warn(feature, `Could not find a name for behavior.`);
619 return;
620 }
621
622 const [namespacePath, className] = splitReference(feature.className);
623 const ns = findOrCreateNamespace(this.root, namespacePath);
624
625 // An interface with the properties and methods that this behavior adds to
626 // an element. Note that behaviors are not classes, they are just data
627 // objects which the Polymer library uses to augment element classes.
628 ns.members.push(new ts.Interface({
629 name: className,
630 description: feature.description || feature.summary,
631 extends: feature.behaviorAssignments.map((b) => b.identifier),
632 properties: this.handleProperties(feature.properties.values()),
633 methods: this.handleMethods(feature.methods.values()),
634 }));
635
636 // The value that contains the actual definition of the behavior for
637 // Polymer. It's not important to know the shape of this object, so the
638 // `object` type is good enough. The main use of this is to make statements
639 // like `Polymer.mixinBehaviors([Polymer.SomeBehavior], ...)` compile.
640 ns.members.push(new ts.ConstValue({
641 name: className,
642 type: new ts.NameType('object'),
643 }));
644 }
645
646 /**
647 * Add the given Mixin to the given TypeScript declarations document.
648 */
649 private handleMixin(feature: analyzer.ElementMixin) {
650 const [namespacePath, mixinName] = splitReference(feature.name);
651 const parentNamespace = findOrCreateNamespace(this.root, namespacePath);
652 const transitiveMixins = [...this.transitiveMixins(feature)];
653 const constructorName = mixinName + 'Constructor';
654
655 // The mixin function. It takes a constructor, and returns an intersection
656 // of 1) the given constructor, 2) the constructor for this mixin, 3) the
657 // constructors for any other mixins that this mixin also applies.
658 parentNamespace.members.push(new ts.Function({
659 name: mixinName,
660 description: feature.description,
661 templateTypes: ['T extends new (...args: any[]) => {}'],
662 params: [
663 new ts.ParamType({name: 'base', type: new ts.NameType('T')}),
664 ],
665 returns: new ts.IntersectionType([
666 new ts.NameType('T'),
667 new ts.NameType(constructorName),
668 ...transitiveMixins.map(
669 (mixin) => new ts.NameType(mixin.name + 'Constructor'))
670 ]),
671 }));
672
673 if (this.root.isEsModule) {
674 // We need to import all of the synthetic constructor interfaces that our
675 // own signature references. We can assume they're exported from the same
676 // module that the mixin is defined in.
677 for (const mixin of transitiveMixins) {
678 if (mixin.sourceRange === undefined) {
679 continue;
680 }
681 const rootRelative =
682 analyzerUrlToRelativePath(mixin.sourceRange.file, this.rootDir);
683 if (rootRelative === undefined) {
684 continue;
685 }
686 const fileRelative =
687 path.relative(path.dirname(this.root.path), rootRelative);
688 const fromModuleSpecifier =
689 fileRelative.startsWith('.') ? fileRelative : './' + fileRelative;
690 const identifiers = [{identifier: mixin.name + 'Constructor'}];
691 if (!getImportedIdentifiers(this.root).has(mixin.name)) {
692 identifiers.push({identifier: mixin.name});
693 }
694 this.root.members.push(new ts.Import({
695 identifiers,
696 fromModuleSpecifier,
697 }));
698 }
699 }
700
701 // The interface for a constructor of this mixin. Returns the instance
702 // interface (see below) when instantiated, and may also have methods of its
703 // own (static methods from the mixin class).
704 parentNamespace.members.push(new ts.Interface({
705 name: constructorName,
706 methods: [
707 new ts.Method({
708 name: 'new',
709 params: [
710 new ts.ParamType({
711 name: 'args',
712 type: new ts.ArrayType(ts.anyType),
713 rest: true,
714 }),
715 ],
716 returns: new ts.NameType(mixinName),
717 }),
718 ...this.handleMethods(feature.staticMethods.values()),
719 ],
720 }));
721
722 if (this.root.isEsModule) {
723 // If any other mixin applies us, it will need to import our synthetic
724 // constructor interface.
725 this.root.members.push(
726 new ts.Export({identifiers: [{identifier: constructorName}]}));
727 }
728
729 // The interface for instances of this mixin. Has the same name as the
730 // function.
731 parentNamespace.members.push(
732 new ts.Interface({
733 name: mixinName,
734 properties: this.handleProperties(feature.properties.values()),
735 methods: this.handleMethods(feature.methods.values()),
736 extends: transitiveMixins.map((mixin) => mixin.name),
737 }),
738 );
739 }
740
741 /**
742 * Mixins can automatically apply other mixins, indicated by the @appliesMixin
743 * annotation. However, since those mixins may themselves apply other mixins,
744 * to know the full set of them we need to traverse down the tree.
745 */
746 private transitiveMixins(
747 parentMixin: analyzer.ElementMixin,
748 result?: Set<analyzer.ElementMixin>): Set<analyzer.ElementMixin> {
749 if (result === undefined) {
750 result = new Set();
751 }
752 for (const childRef of parentMixin.mixins) {
753 const childMixinSet = this.analysis.getFeatures(
754 {id: childRef.identifier, kind: 'element-mixin'});
755 if (childMixinSet.size !== 1) {
756 this.warn(
757 parentMixin,
758 `Found ${childMixinSet.size} features for mixin ` +
759 `${childRef.identifier}, expected 1.`);
760 continue;
761 }
762 const childMixin = childMixinSet.values().next().value;
763 result.add(childMixin);
764 this.transitiveMixins(childMixin, result);
765 }
766 return result;
767 }
768
769 /**
770 * Add the given Class to the given TypeScript declarations document.
771 */
772 private handleClass(feature: analyzer.Class) {
773 if (!feature.className) {
774 this.warn(feature, `Could not find a name for class.`);
775 return;
776 }
777 const [namespacePath, name] = splitReference(feature.className);
778 const m = new ts.Class({name});
779 m.description = feature.description;
780 m.properties = this.handleProperties(feature.properties.values());
781 m.methods = [
782 ...this.handleMethods(feature.staticMethods.values(), {isStatic: true}),
783 ...this.handleMethods(feature.methods.values())
784 ];
785 m.constructorMethod =
786 this.handleConstructorMethod(feature.constructorMethod);
787 if (feature.superClass !== undefined) {
788 m.extends = feature.superClass.identifier;
789 }
790 m.mixins = feature.mixins.map((mixin) => mixin.identifier);
791 findOrCreateNamespace(this.root, namespacePath).members.push(m);
792 }
793
794 /**
795 * Add the given Function to the given TypeScript declarations document.
796 */
797 private handleFunction(feature: AnalyzerFunction) {
798 const [namespacePath, name] = splitReference(feature.name);
799
800 const f = new ts.Function({
801 name,
802 description: feature.description || feature.summary,
803 templateTypes: feature.templateTypes,
804 returns: closureTypeToTypeScript(
805 feature.return && feature.return.type, feature.templateTypes),
806 returnsDescription: feature.return && feature.return.desc
807 });
808
809 for (const param of feature.params || []) {
810 // TODO Handle parameter default values. Requires support from Analyzer
811 // which only handles this for class method parameters currently.
812 f.params.push(closureParamToTypeScript(
813 param.name, param.type, feature.templateTypes));
814 }
815
816 findOrCreateNamespace(this.root, namespacePath).members.push(f);
817 }
818
819 /**
820 * Convert the given Analyzer properties to their TypeScript declaration
821 * equivalent.
822 */
823 private handleProperties(analyzerProperties: Iterable<analyzer.Property>):
824 ts.Property[] {
825 const tsProperties = <ts.Property[]>[];
826 for (const property of analyzerProperties) {
827 if (property.inheritedFrom || property.privacy === 'private' ||
828 this.excludeIdentifiers.has(property.name)) {
829 continue;
830 }
831 const p = new ts.Property({
832 name: property.name,
833 // TODO If this is a Polymer property with no default value, then the
834 // type should really be `<type>|undefined`.
835 type: closureTypeToTypeScript(property.type),
836 readOnly: property.readOnly,
837 });
838 p.description = property.description || '';
839 tsProperties.push(p);
840 }
841 return tsProperties;
842 }
843
844
845 /**
846 * Convert the given Analyzer methods to their TypeScript declaration
847 * equivalent.
848 */
849 private handleMethods(analyzerMethods: Iterable<analyzer.Method>, opts?: {
850 isStatic?: boolean
851 }): ts.Method[] {
852 const tsMethods = <ts.Method[]>[];
853 for (const method of analyzerMethods) {
854 if (method.inheritedFrom || method.privacy === 'private' ||
855 this.excludeIdentifiers.has(method.name)) {
856 continue;
857 }
858
859 tsMethods.push(this.handleMethod(method, opts));
860 }
861 return tsMethods;
862 }
863
864 /**
865 * Convert the given Analyzer method to the equivalent TypeScript declaration
866 */
867 private handleMethod(method: analyzer.Method, opts?: {isStatic?: boolean}):
868 ts.Method {
869 const m = new ts.Method({
870 name: method.name,
871 returns: closureTypeToTypeScript(method.return && method.return.type),
872 returnsDescription: method.return && method.return.desc,
873 isStatic: opts && opts.isStatic,
874 ignoreTypeCheck: this.documentationHasSuppressTypeCheck(method.jsdoc)
875 });
876 m.description = method.description || '';
877
878 let requiredAhead = false;
879 for (const param of reverseIter(method.params || [])) {
880 const tsParam = closureParamToTypeScript(param.name, param.type);
881 tsParam.description = param.description || '';
882
883 if (param.defaultValue !== undefined) {
884 // Parameters with default values generally behave like optional
885 // parameters. However, unlike optional parameters, they may be
886 // followed by a required parameter, in which case the default value is
887 // set by explicitly passing undefined.
888 if (!requiredAhead) {
889 tsParam.optional = true;
890 } else {
891 tsParam.type = new ts.UnionType([tsParam.type, ts.undefinedType]);
892 }
893 } else if (!tsParam.optional) {
894 requiredAhead = true;
895 }
896
897 // Analyzer might know this is a rest parameter even if there was no
898 // JSDoc type annotation (or if it was wrong).
899 tsParam.rest = tsParam.rest || !!param.rest;
900 if (tsParam.rest && tsParam.type.kind !== 'array') {
901 // Closure rest parameter types are written without the Array syntax,
902 // but in TypeScript they must be explicitly arrays.
903 tsParam.type = new ts.ArrayType(tsParam.type);
904 }
905
906 m.params.unshift(tsParam);
907 }
908 return m;
909 }
910
911 private documentationHasSuppressTypeCheck(annotation: jsdoc.Annotation|
912 undefined): boolean {
913 if (!annotation) {
914 return false;
915 }
916
917 const annotationValue = annotation.tags.find((e) => e.title === 'suppress');
918 return annotationValue && annotationValue.description === '{checkTypes}' ||
919 false;
920 }
921
922 private handleConstructorMethod(method?: analyzer.Method): ts.Method
923 |undefined {
924 if (!method) {
925 return;
926 }
927 const m = this.handleMethod(method);
928 m.returns = undefined;
929 return m;
930 }
931
932 /**
933 * Add the given namespace to the given TypeScript declarations document.
934 */
935 private handleNamespace(feature: analyzer.Namespace) {
936 const ns = findOrCreateNamespace(this.root, feature.name.split('.'));
937 if (ns.kind === 'namespace') {
938 ns.description = feature.description || feature.summary || '';
939 }
940 }
941
942 /**
943 * Add a JavaScript import to the TypeScript declarations.
944 */
945 private handleJsImport(feature: analyzer.JavascriptImport) {
946 const node = feature.astNode.node;
947
948 if (babel.isImportDeclaration(node)) {
949 const identifiers: ts.ImportSpecifier[] = [];
950 for (const specifier of node.specifiers) {
951 if (babel.isImportSpecifier(specifier)) {
952 // E.g. import {Foo, Bar as Baz} from './foo.js'
953 if (this.isResolvable(specifier.imported.name, feature)) {
954 identifiers.push({
955 identifier: specifier.imported.name,
956 alias: specifier.local.name,
957 });
958 }
959
960 } else if (babel.isImportDefaultSpecifier(specifier)) {
961 // E.g. import foo from './foo.js'
962 if (this.isResolvable('default', feature)) {
963 identifiers.push({
964 identifier: 'default',
965 alias: specifier.local.name,
966 });
967 }
968
969 } else if (babel.isImportNamespaceSpecifier(specifier)) {
970 // E.g. import * as foo from './foo.js'
971 identifiers.push({
972 identifier: ts.AllIdentifiers,
973 alias: specifier.local.name,
974 });
975 this.forceResolvable.add(specifier.local.name);
976 }
977 }
978
979 if (identifiers.length > 0) {
980 this.root.members.push(new ts.Import({
981 identifiers: identifiers,
982 fromModuleSpecifier: node.source && node.source.value,
983 }));
984 }
985 } else if (
986 // Exports are handled as exports below. Analyzer also considers them
987 // imports when they export from another module.
988 !babel.isExportNamedDeclaration(node) &&
989 !babel.isExportAllDeclaration(node)) {
990 this.warn(feature, `Import with AST type ${node.type} not supported.`);
991 }
992 }
993
994
995 /**
996 * Add a JavaScript export to the TypeScript declarations.
997 */
998 private handleJsExport(feature: analyzer.Export) {
999 const node = feature.astNode.node;
1000
1001 if (babel.isExportAllDeclaration(node)) {
1002 // E.g. export * from './foo.js'
1003 this.root.members.push(new ts.Export({
1004 identifiers: ts.AllIdentifiers,
1005 fromModuleSpecifier: node.source && node.source.value,
1006 }));
1007
1008 } else if (babel.isExportNamedDeclaration(node)) {
1009 const identifiers = [];
1010
1011 if (node.declaration) {
1012 // E.g. export class Foo {}
1013 for (const identifier of feature.identifiers) {
1014 if (this.isResolvable(identifier, feature)) {
1015 identifiers.push({identifier});
1016 }
1017 }
1018
1019 } else {
1020 // E.g. export {Foo, Bar as Baz}
1021 for (const specifier of node.specifiers) {
1022 if (this.isResolvable(specifier.exported.name, feature) ||
1023 this.isResolvable(specifier.local.name, feature)) {
1024 identifiers.push({
1025 identifier: specifier.local.name,
1026 alias: specifier.exported.name,
1027 });
1028 }
1029 }
1030 }
1031
1032 if (identifiers.length > 0) {
1033 this.root.members.push(new ts.Export({
1034 identifiers,
1035 fromModuleSpecifier: node.source && node.source.value,
1036 }));
1037 }
1038
1039 } else {
1040 this.warn(
1041 feature,
1042 `Export feature with AST node type ${node.type} not supported.`);
1043 }
1044 }
1045
1046 /**
1047 * True if the given identifier can be resolved to a feature that will be
1048 * exported as a TypeScript type.
1049 */
1050 private isResolvable(
1051 identifier: string,
1052 fromFeature: analyzer.JavascriptImport|analyzer.Export) {
1053 if (this.forceResolvable.has(identifier)) {
1054 return true;
1055 }
1056 if (this.excludeIdentifiers.has(identifier)) {
1057 return false;
1058 }
1059 const resolved =
1060 resolveImportExportFeature(fromFeature, identifier, this.analyzerDoc);
1061 return resolved !== undefined && resolved.feature !== undefined &&
1062 !isPrivate(resolved.feature) && !isBehaviorImpl(resolved);
1063 }
1064
1065 /**
1066 * Add an HTML import to a TypeScript declarations file. For a given HTML
1067 * import, we assume there is a corresponding declarations file that was
1068 * generated by this same process.
1069 */
1070 private handleHtmlImport(feature: analyzer.Import) {
1071 let sourcePath = analyzerUrlToRelativePath(feature.url, this.rootDir);
1072 if (sourcePath === undefined) {
1073 this.warn(
1074 feature,
1075 `Skipping HTML import without local file URL: ${feature.url}`);
1076 return;
1077 }
1078 // When we analyze a package's Git repo, our dependencies are installed to
1079 // "<repo>/bower_components". However, when this package is itself installed
1080 // as a dependency, our own dependencies will instead be siblings, one
1081 // directory up the tree.
1082 //
1083 // Analyzer (since 2.5.0) will set an import feature's URL to the resolved
1084 // dependency path as discovered on disk. An import for "../foo/foo.html"
1085 // will be resolved to "bower_components/foo/foo.html". Transform the URL
1086 // back to the style that will work when this package is installed as a
1087 // dependency.
1088 sourcePath =
1089 sourcePath.replace(/^(bower_components|node_modules)\//, '../');
1090
1091 // Polymer is a special case where types are output to the "types/"
1092 // subdirectory instead of as sibling files, in order to avoid cluttering
1093 // the repo. It would be more pure to store this fact in the Polymer
1094 // gen-tsd.json config file and discover it when generating types for repos
1095 // that depend on it, but that's probably more complicated than we need,
1096 // assuming no other repos deviate from emitting their type declarations as
1097 // sibling files.
1098 sourcePath = sourcePath.replace(/^\.\.\/polymer\//, '../polymer/types/');
1099
1100 this.root.referencePaths.add(path.relative(
1101 path.dirname(this.root.path), makeDeclarationsFilename(sourcePath)));
1102 }
1103}
1104
1105/**
1106 * Iterate over an array backwards.
1107 */
1108function* reverseIter<T>(arr: T[]) {
1109 for (let i = arr.length - 1; i >= 0; i--) {
1110 yield arr[i];
1111 }
1112}
1113
1114/**
1115 * Find a document's global namespace declaration, or create one if it doesn't
1116 * exist.
1117 */
1118function findOrCreateGlobalNamespace(doc: ts.Document): ts.GlobalNamespace {
1119 for (const member of doc.members) {
1120 if (member.kind === 'globalNamespace') {
1121 return member;
1122 }
1123 }
1124 const globalNamespace = new ts.GlobalNamespace();
1125 doc.members.push(globalNamespace);
1126 return globalNamespace;
1127}
1128
1129/**
1130 * Traverse the given node to find the namespace AST node with the given path.
1131 * If it could not be found, add one and return it.
1132 */
1133function findOrCreateNamespace(
1134 root: ts.Document|ts.Namespace|ts.GlobalNamespace,
1135 path: string[]): ts.Document|ts.Namespace|ts.GlobalNamespace {
1136 if (!path.length) {
1137 return root;
1138 }
1139 let first: ts.Namespace|undefined;
1140 for (const member of root.members) {
1141 if (member.kind === 'namespace' && member.name === path[0]) {
1142 first = member;
1143 break;
1144 }
1145 }
1146 if (!first) {
1147 first = new ts.Namespace({name: path[0]});
1148 root.members.push(first);
1149 }
1150 return findOrCreateNamespace(first, path.slice(1));
1151}
1152
1153/**
1154 * Traverse the given node to find the interface AST node with the given path.
1155 * If it could not be found, add one and return it.
1156 */
1157function findOrCreateInterface(
1158 root: ts.Document|ts.Namespace|ts.GlobalNamespace,
1159 reference: string): ts.Interface {
1160 const [namespacePath, name] = splitReference(reference);
1161 const namespace_ = findOrCreateNamespace(root, namespacePath);
1162 for (const member of namespace_.members) {
1163 if (member.kind === 'interface' && member.name === name) {
1164 return member;
1165 }
1166 }
1167 const i = new ts.Interface({name});
1168 namespace_.members.push(i);
1169 return i;
1170}
1171
1172/**
1173 * Type guard that checks if a Polymer Analyzer feature is a PolymerElement.
1174 */
1175function isPolymerElement(feature: analyzer.Feature):
1176 feature is analyzer.PolymerElement {
1177 return feature.kinds.has('polymer-element');
1178}
1179
1180/**
1181 * Return whether a reference looks like it is a FooBehaviorImpl style behavior
1182 * object, which we want to ignore.
1183 *
1184 * Polymer behavior libraries are often written like:
1185 *
1186 * /** @polymerBehavior FooBehavior *\/
1187 * export const FooBehaviorImpl = {};
1188 *
1189 * /** @polymerBehavior *\/
1190 * export const FooBehavior = [FooBehaviorImpl, OtherBehavior];
1191 *
1192 * In this case, Analyzer merges FooBehaviorImpl into FooBehavior and does not
1193 * emit a behavior feature for FooBehaviorImpl. However, there is still an
1194 * export feature for FooBehaviorImpl, so we exclude it here.
1195 */
1196function isBehaviorImpl(reference: analyzer.Reference<analyzer.Feature>) {
1197 return reference.feature !== undefined &&
1198 reference.feature.kinds.has('behavior') &&
1199 (reference.feature as analyzer.PolymerBehavior).name !==
1200 reference.identifier;
1201}
1202
1203interface MaybePrivate {
1204 privacy?: 'public'|'private'|'protected';
1205}
1206
1207/**
1208 * Return whether the given Analyzer feature has "private" visibility.
1209 */
1210function isPrivate(feature: analyzer.Feature&MaybePrivate): boolean {
1211 return feature.privacy === 'private';
1212}
1213
1214/**
1215 * Convert kebab-case to CamelCase.
1216 */
1217function kebabToCamel(s: string): string {
1218 return s.replace(/(^|-)(.)/g, (_match, _p0, p1) => p1.toUpperCase());
1219}
1220
1221/**
1222 * Split a reference into an array of namespace path parts, and a name part
1223 * (e.g. `"Foo.Bar.Baz"` => `[ ["Foo", "Bar"], "Baz" ]`).
1224 */
1225function splitReference(reference: string): [string[], string] {
1226 const parts = reference.split('.');
1227 const namespacePath = parts.slice(0, -1);
1228 const name = parts[parts.length - 1];
1229 return [namespacePath, name];
1230}