UNPKG

19.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 minimatch from 'minimatch';
13import * as path from 'path';
14import * as analyzer from 'polymer-analyzer';
15import {Function as AnalyzerFunction} from 'polymer-analyzer/lib/javascript/function';
16
17import {closureParamToTypeScript, closureTypeToTypeScript} from './closure-types';
18import * as ts from './ts-ast';
19
20/**
21 * Configuration for declaration generation.
22 */
23export interface Config {
24 /**
25 * Skip source files whose paths match any of these glob patterns. If
26 * undefined, defaults to excluding directories ending in "test" or "demo".
27 */
28 exclude?: string[];
29
30 /**
31 * Remove any triple-slash references to these files, specified as paths
32 * relative to the analysis root directory.
33 */
34 removeReferences?: string[];
35
36 /**
37 * Additional files to insert as triple-slash reference statements. Given the
38 * map `a: b[]`, a will get an additional reference statement for each file
39 * path in b. All paths are relative to the analysis root directory.
40 */
41 addReferences?: {[filepath: string]: string[]};
42}
43
44/**
45 * Analyze all files in the given directory using Polymer Analyzer, and return
46 * TypeScript declaration document strings in a map keyed by relative path.
47 */
48export async function generateDeclarations(
49 rootDir: string, config: Config): Promise<Map<string, string>> {
50 const a = new analyzer.Analyzer({
51 urlLoader: new analyzer.FSUrlLoader(rootDir),
52 urlResolver: new analyzer.PackageUrlResolver(),
53 });
54 const analysis = await a.analyzePackage();
55 const outFiles = new Map<string, string>();
56 for (const tsDoc of analyzerToAst(analysis, config, rootDir)) {
57 outFiles.set(tsDoc.path, tsDoc.serialize())
58 }
59 return outFiles;
60}
61
62/**
63 * Make TypeScript declaration documents from the given Polymer Analyzer
64 * result.
65 */
66function analyzerToAst(
67 analysis: analyzer.Analysis, config: Config, rootDir: string):
68 ts.Document[] {
69 const exclude = (config.exclude || ['test/**', 'demo/**'])
70 .map((p) => new minimatch.Minimatch(p));
71 const addReferences = config.addReferences || {};
72 const removeReferencesResolved = new Set(
73 (config.removeReferences || []).map((r) => path.resolve(rootDir, r)));
74
75 // Analyzer can produce multiple JS documents with the same URL (e.g. an
76 // HTML file with multiple inline scripts). We also might have multiple
77 // files with the same basename (e.g. `foo.html` with an inline script,
78 // and `foo.js`). We want to produce one declarations file for each
79 // basename, so we first group Analyzer documents by their declarations
80 // filename.
81 const declarationDocs = new Map<string, analyzer.Document[]>();
82 for (const jsDoc of analysis.getFeatures({kind: 'js-document'})) {
83 if (exclude.some((r) => r.match(jsDoc.url))) {
84 continue;
85 }
86 const filename = makeDeclarationsFilename(jsDoc.url);
87 let docs = declarationDocs.get(filename);
88 if (!docs) {
89 docs = [];
90 declarationDocs.set(filename, docs);
91 }
92 docs.push(jsDoc);
93 }
94
95 const tsDocs = [];
96 for (const [declarationsFilename, analyzerDocs] of declarationDocs) {
97 const tsDoc = new ts.Document({
98 path: declarationsFilename,
99 header: makeHeader(analyzerDocs.map((d) => d.url)),
100 });
101 for (const analyzerDoc of analyzerDocs) {
102 handleDocument(analyzerDoc, tsDoc);
103 }
104 for (const ref of tsDoc.referencePaths) {
105 const resolvedRef = path.resolve(rootDir, path.dirname(tsDoc.path), ref);
106 if (removeReferencesResolved.has(resolvedRef)) {
107 tsDoc.referencePaths.delete(ref);
108 }
109 }
110 for (const ref of addReferences[tsDoc.path] || []) {
111 tsDoc.referencePaths.add(path.relative(path.dirname(tsDoc.path), ref));
112 }
113 tsDoc.simplify();
114 // Include even documents with no members. They might be dependencies of
115 // other files via the HTML import graph, and it's simpler to have empty
116 // files than to try and prune the references (especially across packages).
117 tsDocs.push(tsDoc);
118 }
119 return tsDocs;
120}
121
122/**
123 * Create a TypeScript declarations filename for the given source document URL.
124 * Simply replaces the file extension with `d.ts`.
125 */
126function makeDeclarationsFilename(sourceUrl: string): string {
127 const parsed = path.parse(sourceUrl);
128 return path.join(parsed.dir, parsed.name) + '.d.ts';
129}
130
131/**
132 * Generate the header comment to show at the top of a declarations document.
133 */
134function makeHeader(sourceUrls: string[]): string {
135 return `DO NOT EDIT
136
137This file was automatically generated by
138 https://github.com/Polymer/gen-typescript-declarations
139
140To modify these typings, edit the source file(s):
141${sourceUrls.map((url) => ' ' + url).join('\n')}`;
142}
143
144interface MaybePrivate {
145 privacy?: 'public'|'private'|'protected'
146}
147
148/**
149 * Extend the given TypeScript declarations document with all of the relevant
150 * items in the given Polymer Analyzer document.
151 */
152function handleDocument(doc: analyzer.Document, root: ts.Document) {
153 for (const feature of doc.getFeatures()) {
154 if ((feature as MaybePrivate).privacy === 'private') {
155 continue;
156 }
157 if (feature.kinds.has('element')) {
158 handleElement(feature as analyzer.Element, root);
159 } else if (feature.kinds.has('behavior')) {
160 handleBehavior(feature as analyzer.PolymerBehavior, root);
161 } else if (feature.kinds.has('element-mixin')) {
162 handleMixin(feature as analyzer.ElementMixin, root);
163 } else if (feature.kinds.has('class')) {
164 handleClass(feature as analyzer.Class, root);
165 } else if (feature.kinds.has('function')) {
166 handleFunction(feature as AnalyzerFunction, root);
167 } else if (feature.kinds.has('namespace')) {
168 handleNamespace(feature as analyzer.Namespace, root);
169 } else if (feature.kinds.has('import')) {
170 // Sometimes an Analyzer document includes an import feature that is
171 // inbound (things that depend on me) instead of outbound (things I
172 // depend on). For example, if an HTML file has a <script> tag for a JS
173 // file, then the JS file's Analyzer document will include that <script>
174 // tag as an import feature. We only care about outbound dependencies,
175 // hence this check.
176 if (feature.sourceRange && feature.sourceRange.file === doc.url) {
177 handleImport(feature as analyzer.Import, root);
178 }
179 }
180 }
181}
182
183/**
184 * Add the given Element to the given TypeScript declarations document.
185 */
186function handleElement(feature: analyzer.Element, root: ts.Document) {
187 // Whether this element has a constructor that is assigned and can be called.
188 // If it does we'll emit a class, otherwise an interface.
189 let constructable;
190
191 let fullName; // Fully qualified reference, e.g. `Polymer.DomModule`.
192 let shortName; // Just the last part of the name, e.g. `DomModule`.
193 let parent; // Where in the namespace tree does this live.
194
195 if (feature.className) {
196 constructable = true;
197 let namespacePath;
198 [namespacePath, shortName] = splitReference(feature.className);
199 fullName = feature.className;
200 parent = findOrCreateNamespace(root, namespacePath);
201
202 } else if (feature.tagName) {
203 constructable = false;
204 shortName = kebabToCamel(feature.tagName);
205 fullName = shortName;
206 // We're going to pollute the global scope with an interface.
207 parent = root;
208
209 } else {
210 console.error('Could not find a name.');
211 return;
212 }
213
214 if (constructable) {
215 // TODO How do we handle behaviors with classes?
216 const c = new ts.Class({
217 name: shortName,
218 description: feature.description || feature.summary,
219 extends: (feature.extends) ||
220 (isPolymerElement(feature) ? 'Polymer.Element' : 'HTMLElement'),
221 mixins: feature.mixins.map((mixin) => mixin.identifier),
222 properties: handleProperties(feature.properties.values()),
223 methods: handleMethods(feature.methods.values()),
224 });
225 parent.members.push(c);
226
227 } else {
228 // TODO How do we handle mixins when we are emitting an interface? We don't
229 // currently define interfaces for mixins, so we can't just add them to
230 // extends.
231 const i = new ts.Interface({
232 name: shortName,
233 description: feature.description || feature.summary,
234 properties: handleProperties(feature.properties.values()),
235 methods: handleMethods(feature.methods.values()),
236 });
237
238 if (isPolymerElement(feature)) {
239 i.extends.push('Polymer.Element');
240 i.extends.push(...feature.behaviorAssignments.map(
241 (behavior) => behavior.name));
242 }
243
244 parent.members.push(i);
245 }
246
247 // The `HTMLElementTagNameMap` global interface maps custom element tag names
248 // to their definitions, so that TypeScript knows that e.g.
249 // `dom.createElement('my-foo')` returns a `MyFoo`. Augment the map with this
250 // custom element.
251 if (feature.tagName) {
252 const elementMap = findOrCreateInterface(root, 'HTMLElementTagNameMap');
253 elementMap.properties.push(new ts.Property({
254 name: feature.tagName,
255 type: new ts.NameType(fullName),
256 }));
257 }
258}
259
260/**
261 * Add the given Polymer Behavior to the given TypeScript declarations
262 * document.
263 */
264function handleBehavior(feature: analyzer.PolymerBehavior, root: ts.Document) {
265 if (!feature.className) {
266 console.error('Could not find a name for behavior.');
267 return;
268 }
269 const [namespacePath, className] = splitReference(feature.className);
270 const i = new ts.Interface({name: className});
271 i.description = feature.description || feature.summary;
272 i.properties = handleProperties(feature.properties.values());
273 i.methods = handleMethods(feature.methods.values());
274 findOrCreateNamespace(root, namespacePath).members.push(i);
275}
276
277/**
278 * Add the given Mixin to the given TypeScript declarations document.
279 */
280function handleMixin(feature: analyzer.ElementMixin, root: ts.Document) {
281 const [namespacePath, name] = splitReference(feature.name);
282 const namespace_ = findOrCreateNamespace(root, namespacePath);
283
284 // We represent mixins in two parts: a mixin function that is called to
285 // augment a given class with this mixin, and an interface with the
286 // properties and methods that are added by this mixin. We can use the same
287 // name for both parts because one is in value space, and the other is in
288 // type space.
289
290 const function_ = new ts.Mixin({name});
291 function_.description = feature.description;
292 function_.interfaces = [name, ...feature.mixins.map((m) => m.identifier)];
293 namespace_.members.push(function_);
294
295 const interface_ = new ts.Interface({name});
296 interface_.properties = handleProperties(feature.properties.values());
297 interface_.methods = handleMethods(feature.methods.values());
298 namespace_.members.push(interface_);
299}
300
301/**
302 * Add the given Class to the given TypeScript declarations document.
303 */
304function handleClass(feature: analyzer.Class, root: ts.Document) {
305 if (!feature.className) {
306 console.error('Could not find a name for class.');
307 return;
308 }
309 const [namespacePath, name] = splitReference(feature.className);
310 const m = new ts.Class({name});
311 m.description = feature.description;
312 m.properties = handleProperties(feature.properties.values());
313 m.methods = handleMethods(feature.methods.values());
314 findOrCreateNamespace(root, namespacePath).members.push(m);
315}
316
317
318/**
319 * Add the given Function to the given TypeScript declarations document.
320 */
321function handleFunction(feature: AnalyzerFunction, root: ts.Document) {
322 const [namespacePath, name] = splitReference(feature.name);
323
324 const f = new ts.Function({
325 name,
326 description: feature.description || feature.summary,
327 templateTypes: feature.templateTypes,
328 returns: closureTypeToTypeScript(
329 feature.return && feature.return.type, feature.templateTypes),
330 returnsDescription: feature.return && feature.return.desc
331 });
332
333 for (const param of feature.params || []) {
334 // TODO Handle parameter default values. Requires support from Analyzer
335 // which only handles this for class method parameters currently.
336 const {type, optional, rest} =
337 closureParamToTypeScript(param.type, feature.templateTypes);
338 f.params.push(new ts.Param({name: param.name, type, optional, rest}));
339 }
340
341 findOrCreateNamespace(root, namespacePath).members.push(f);
342}
343
344/**
345 * Convert the given Analyzer properties to their TypeScript declaration
346 * equivalent.
347 */
348function handleProperties(analyzerProperties: Iterable<analyzer.Property>):
349 ts.Property[] {
350 const tsProperties = <ts.Property[]>[];
351 for (const property of analyzerProperties) {
352 if (property.inheritedFrom || property.privacy === 'private') {
353 continue;
354 }
355 const p = new ts.Property({
356 name: property.name,
357 // TODO If this is a Polymer property with no default value, then the
358 // type should really be `<type>|undefined`.
359 type: closureTypeToTypeScript(property.type),
360 });
361 p.description = property.description || '';
362 tsProperties.push(p);
363 }
364 return tsProperties;
365}
366
367
368/**
369 * Convert the given Analyzer methods to their TypeScript declaration
370 * equivalent.
371 */
372function handleMethods(analyzerMethods: Iterable<analyzer.Method>):
373 ts.Method[] {
374 const tsMethods = <ts.Method[]>[];
375 for (const method of analyzerMethods) {
376 if (method.inheritedFrom || method.privacy === 'private') {
377 continue;
378 }
379 const m = new ts.Method({
380 name: method.name,
381 returns: closureTypeToTypeScript(method.return && method.return.type),
382 returnsDescription: method.return && method.return.desc
383 });
384 m.description = method.description || '';
385
386 let requiredAhead = false;
387 for (const param of reverseIter(method.params || [])) {
388 let {type, optional, rest} = closureParamToTypeScript(param.type);
389
390 if (param.defaultValue !== undefined) {
391 // Parameters with default values generally behave like optional
392 // parameters. However, unlike optional parameters, they may be
393 // followed by a required parameter, in which case the default value is
394 // set by explicitly passing undefined.
395 if (!requiredAhead) {
396 optional = true;
397 } else {
398 type = new ts.UnionType([type, ts.undefinedType]);
399 }
400 } else if (!optional) {
401 requiredAhead = true;
402 }
403
404 // Analyzer might know this is a rest parameter even if there was no
405 // JSDoc type annotation (or if it was wrong).
406 rest = rest || !!param.rest;
407 if (rest && type.kind !== 'array') {
408 // Closure rest parameter types are written without the Array syntax,
409 // but in TypeScript they must be explicitly arrays.
410 type = new ts.ArrayType(type);
411 }
412
413 m.params.unshift(new ts.Param({
414 name: param.name,
415 description: param.description,
416 type,
417 optional,
418 rest
419 }));
420 }
421
422 tsMethods.push(m);
423 }
424 return tsMethods;
425}
426
427/**
428 * Iterate over an array backwards.
429 */
430function* reverseIter<T>(arr: T[]) {
431 for (let i = arr.length - 1; i >= 0; i--) {
432 yield arr[i];
433 }
434}
435
436/**
437 * Add the given namespace to the given TypeScript declarations document.
438 */
439function handleNamespace(feature: analyzer.Namespace, tsDoc: ts.Document) {
440 const ns = findOrCreateNamespace(tsDoc, feature.name.split('.'));
441 if (ns.kind === 'namespace') {
442 ns.description = feature.description || feature.summary || '';
443 }
444}
445
446/**
447 * Add an HTML import to a TypeScript declarations file. For a given HTML
448 * import, we assume there is a corresponding declarations file that was
449 * generated by this same process.
450 *
451 * TODO If the import was to an external package, we currently don't know if
452 * the typings file actually exists. Also, if we end up placing type
453 * declarations in a types/ subdirectory, we will need to update these paths to
454 * match.
455 */
456function handleImport(feature: analyzer.Import, tsDoc: ts.Document) {
457 if (!feature.url) {
458 return;
459 }
460 // When we analyze a package's Git repo, our dependencies are installed to
461 // "<repo>/bower_components". However, when this package is itself installed
462 // as a dependency, our own dependencies will instead be siblings, one
463 // directory up the tree.
464 //
465 // Analyzer (since 2.5.0) will set an import feature's URL to the resolved
466 // dependency path as discovered on disk. An import for "../foo/foo.html"
467 // will be resolved to "bower_components/foo/foo.html". Transform the URL
468 // back to the style that will work when this package is installed as a
469 // dependency.
470 const url = feature.url.replace(/^(bower_components|node_modules)\//, '../');
471 tsDoc.referencePaths.add(
472 path.relative(path.dirname(tsDoc.path), makeDeclarationsFilename(url)));
473}
474
475/**
476 * Traverse the given node to find the namespace AST node with the given path.
477 * If it could not be found, add one and return it.
478 */
479function findOrCreateNamespace(
480 root: ts.Document|ts.Namespace, path: string[]): ts.Document|ts.Namespace {
481 if (!path.length) {
482 return root;
483 }
484 let first: ts.Namespace|undefined;
485 for (const member of root.members) {
486 if (member.kind === 'namespace' && member.name === path[0]) {
487 first = member;
488 break;
489 }
490 }
491 if (!first) {
492 first = new ts.Namespace({name: path[0]});
493 root.members.push(first);
494 }
495 return findOrCreateNamespace(first, path.slice(1));
496}
497
498/**
499 * Traverse the given node to find the interface AST node with the given path.
500 * If it could not be found, add one and return it.
501 */
502function findOrCreateInterface(
503 root: ts.Document|ts.Namespace, reference: string): ts.Interface {
504 const [namespacePath, name] = splitReference(reference);
505 const namespace_ = findOrCreateNamespace(root, namespacePath);
506 for (const member of namespace_.members) {
507 if (member.kind === 'interface' && member.name === name) {
508 return member;
509 }
510 }
511 const i = new ts.Interface({name});
512 namespace_.members.push(i);
513 return i;
514}
515
516/**
517 * Type guard that checks if a Polymer Analyzer feature is a PolymerElement.
518 */
519function isPolymerElement(feature: analyzer.Feature):
520 feature is analyzer.PolymerElement {
521 return feature.kinds.has('polymer-element');
522}
523
524/**
525 * Convert kebab-case to CamelCase.
526 */
527function kebabToCamel(s: string): string {
528 return s.replace(/(^|-)(.)/g, (_match, _p0, p1) => p1.toUpperCase());
529}
530
531/**
532 * Split a reference into an array of namespace path parts, and a name part
533 * (e.g. `"Foo.Bar.Baz"` => `[ ["Foo", "Bar"], "Baz" ]`).
534 */
535function splitReference(reference: string): [string[], string] {
536 const parts = reference.split('.');
537 const namespacePath = parts.slice(0, -1);
538 const name = parts[parts.length - 1];
539 return [namespacePath, name];
540}