Skip to content

Commit a6ce57a

Browse files
authored
Merge pull request #1916 from javier-garcia-meteologica/import_type_support_simplicity
[api-extractor] Support for import() type
2 parents ae5b5b6 + da0b60c commit a6ce57a

35 files changed

+1574
-129
lines changed

apps/api-extractor/src/analyzer/AstImport.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,12 @@ export enum AstImportKind {
2727
/**
2828
* An import statement such as `import x = require("y");`.
2929
*/
30-
EqualsImport
30+
EqualsImport,
31+
32+
/**
33+
* An import statement such as `interface foo { foo: import("bar").a.b.c }`.
34+
*/
35+
ImportType
3136
}
3237

3338
/**
@@ -80,6 +85,9 @@ export class AstImport extends AstSyntheticEntity {
8085
*
8186
* // For AstImportKind.EqualsImport style, exportName would be "x" in this example:
8287
* import x = require("y");
88+
*
89+
* // For AstImportKind.ImportType style, exportName would be "a.b.c" in this example:
90+
* interface foo { foo: import('bar').a.b.c };
8391
* ```
8492
*/
8593
public readonly exportName: string;
@@ -142,6 +150,14 @@ export class AstImport extends AstSyntheticEntity {
142150
return `${options.modulePath}:*`;
143151
case AstImportKind.EqualsImport:
144152
return `${options.modulePath}:=`;
153+
case AstImportKind.ImportType: {
154+
const subKey: string = !options.exportName
155+
? '*' // Equivalent to StarImport
156+
: options.exportName.includes('.') // Equivalent to a named export
157+
? options.exportName.split('.')[0]
158+
: options.exportName;
159+
return `${options.modulePath}:${subKey}`;
160+
}
145161
default:
146162
throw new InternalError('Unknown AstImportKind');
147163
}

apps/api-extractor/src/analyzer/AstSymbolTable.ts

Lines changed: 52 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { AstEntity } from './AstEntity';
1616
import { AstNamespaceImport } from './AstNamespaceImport';
1717
import { MessageRouter } from '../collector/MessageRouter';
1818
import { TypeScriptInternals, IGlobalVariableAnalyzer } from './TypeScriptInternals';
19-
import { StringChecks } from './StringChecks';
19+
import { SyntaxHelpers } from './SyntaxHelpers';
2020
import { SourceFileLocationFormatter } from './SourceFileLocationFormatter';
2121

2222
/**
@@ -88,7 +88,7 @@ export class AstSymbolTable {
8888

8989
// Note that this is a mapping from specific AST nodes that we analyzed, based on the underlying symbol
9090
// for that node.
91-
private readonly _entitiesByIdentifierNode: Map<ts.Identifier, AstEntity | undefined> = new Map<
91+
private readonly _entitiesByNode: Map<ts.Identifier | ts.ImportTypeNode, AstEntity | undefined> = new Map<
9292
ts.Identifier,
9393
AstEntity | undefined
9494
>();
@@ -187,11 +187,11 @@ export class AstSymbolTable {
187187
* @remarks
188188
* Throws an Error if the ts.Identifier is not part of node tree that was analyzed.
189189
*/
190-
public tryGetEntityForIdentifierNode(identifier: ts.Identifier): AstEntity | undefined {
191-
if (!this._entitiesByIdentifierNode.has(identifier)) {
190+
public tryGetEntityForNode(identifier: ts.Identifier | ts.ImportTypeNode): AstEntity | undefined {
191+
if (!this._entitiesByNode.has(identifier)) {
192192
throw new InternalError('tryGetEntityForIdentifier() called for an identifier that was not analyzed');
193193
}
194-
return this._entitiesByIdentifierNode.get(identifier);
194+
return this._entitiesByNode.get(identifier);
195195
}
196196

197197
/**
@@ -261,7 +261,7 @@ export class AstSymbolTable {
261261
// Otherwise that name may come from a quoted string or pseudonym like `__constructor`.
262262
// If the string is not a safe identifier, then we must add quotes.
263263
// Note that if it was quoted but did not need to be quoted, here we will remove the quotes.
264-
if (!StringChecks.isSafeUnquotedMemberIdentifier(unquotedName)) {
264+
if (!SyntaxHelpers.isSafeUnquotedMemberIdentifier(unquotedName)) {
265265
// For API Extractor's purposes, a canonical form is more appropriate than trying to reflect whatever
266266
// appeared in the source code. The code is not even guaranteed to be consistent, for example:
267267
//
@@ -360,8 +360,7 @@ export class AstSymbolTable {
360360
);
361361

362362
if (identifierNode) {
363-
let referencedAstEntity: AstEntity | undefined =
364-
this._entitiesByIdentifierNode.get(identifierNode);
363+
let referencedAstEntity: AstEntity | undefined = this._entitiesByNode.get(identifierNode);
365364
if (!referencedAstEntity) {
366365
const symbol: ts.Symbol | undefined = this._typeChecker.getSymbolAtLocation(identifierNode);
367366
if (!symbol) {
@@ -412,7 +411,7 @@ export class AstSymbolTable {
412411
governingAstDeclaration.astSymbol.isExternal
413412
);
414413

415-
this._entitiesByIdentifierNode.set(identifierNode, referencedAstEntity);
414+
this._entitiesByNode.set(identifierNode, referencedAstEntity);
416415
}
417416
}
418417

@@ -427,19 +426,38 @@ export class AstSymbolTable {
427426
case ts.SyntaxKind.Identifier:
428427
{
429428
const identifierNode: ts.Identifier = node as ts.Identifier;
430-
if (!this._entitiesByIdentifierNode.has(identifierNode)) {
429+
if (!this._entitiesByNode.has(identifierNode)) {
431430
const symbol: ts.Symbol | undefined = this._typeChecker.getSymbolAtLocation(identifierNode);
432431

433432
let referencedAstEntity: AstEntity | undefined = undefined;
434433

435434
if (symbol === governingAstDeclaration.astSymbol.followedSymbol) {
436-
referencedAstEntity = this._fetchEntityForIdentifierNode(
437-
identifierNode,
438-
governingAstDeclaration
439-
);
435+
referencedAstEntity = this._fetchEntityForNode(identifierNode, governingAstDeclaration);
440436
}
441437

442-
this._entitiesByIdentifierNode.set(identifierNode, referencedAstEntity);
438+
this._entitiesByNode.set(identifierNode, referencedAstEntity);
439+
}
440+
}
441+
break;
442+
443+
case ts.SyntaxKind.ImportType:
444+
{
445+
const importTypeNode: ts.ImportTypeNode = node as ts.ImportTypeNode;
446+
let referencedAstEntity: AstEntity | undefined = this._entitiesByNode.get(importTypeNode);
447+
448+
if (!this._entitiesByNode.has(importTypeNode)) {
449+
referencedAstEntity = this._fetchEntityForNode(importTypeNode, governingAstDeclaration);
450+
451+
if (!referencedAstEntity) {
452+
// This should never happen
453+
throw new Error('Failed to fetch entity for import() type node: ' + importTypeNode.getText());
454+
}
455+
456+
this._entitiesByNode.set(importTypeNode, referencedAstEntity);
457+
}
458+
459+
if (referencedAstEntity) {
460+
governingAstDeclaration._notifyReferencedAstEntity(referencedAstEntity);
443461
}
444462
}
445463
break;
@@ -456,23 +474,30 @@ export class AstSymbolTable {
456474
}
457475
}
458476

459-
private _fetchEntityForIdentifierNode(
460-
identifierNode: ts.Identifier,
477+
private _fetchEntityForNode(
478+
node: ts.Identifier | ts.ImportTypeNode,
461479
governingAstDeclaration: AstDeclaration
462480
): AstEntity | undefined {
463-
let referencedAstEntity: AstEntity | undefined = this._entitiesByIdentifierNode.get(identifierNode);
481+
let referencedAstEntity: AstEntity | undefined = this._entitiesByNode.get(node);
464482
if (!referencedAstEntity) {
465-
const symbol: ts.Symbol | undefined = this._typeChecker.getSymbolAtLocation(identifierNode);
466-
if (!symbol) {
467-
throw new Error('Symbol not found for identifier: ' + identifierNode.getText());
468-
}
483+
if (node.kind === ts.SyntaxKind.ImportType) {
484+
referencedAstEntity = this._exportAnalyzer.fetchReferencedAstEntityFromImportTypeNode(
485+
node,
486+
governingAstDeclaration.astSymbol.isExternal
487+
);
488+
} else {
489+
const symbol: ts.Symbol | undefined = this._typeChecker.getSymbolAtLocation(node);
490+
if (!symbol) {
491+
throw new Error('Symbol not found for identifier: ' + node.getText());
492+
}
469493

470-
referencedAstEntity = this._exportAnalyzer.fetchReferencedAstEntity(
471-
symbol,
472-
governingAstDeclaration.astSymbol.isExternal
473-
);
494+
referencedAstEntity = this._exportAnalyzer.fetchReferencedAstEntity(
495+
symbol,
496+
governingAstDeclaration.astSymbol.isExternal
497+
);
498+
}
474499

475-
this._entitiesByIdentifierNode.set(identifierNode, referencedAstEntity);
500+
this._entitiesByNode.set(node, referencedAstEntity);
476501
}
477502
return referencedAstEntity;
478503
}

apps/api-extractor/src/analyzer/ExportAnalyzer.ts

Lines changed: 96 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { SourceFileLocationFormatter } from './SourceFileLocationFormatter';
1313
import { IFetchAstSymbolOptions } from './AstSymbolTable';
1414
import { AstEntity } from './AstEntity';
1515
import { AstNamespaceImport } from './AstNamespaceImport';
16+
import { SyntaxHelpers } from './SyntaxHelpers';
1617

1718
/**
1819
* Exposes the minimal APIs from AstSymbolTable that are needed by ExportAnalyzer.
@@ -416,6 +417,99 @@ export class ExportAnalyzer {
416417
return astSymbol;
417418
}
418419

420+
public fetchReferencedAstEntityFromImportTypeNode(
421+
node: ts.ImportTypeNode,
422+
referringModuleIsExternal: boolean
423+
): AstEntity | undefined {
424+
const externalModulePath: string | undefined = this._tryGetExternalModulePath(node);
425+
426+
if (externalModulePath) {
427+
let exportName: string;
428+
if (node.qualifier) {
429+
// Example input:
430+
// import('api-extractor-lib1-test').Lib1GenericType<number>
431+
//
432+
// Extracted qualifier:
433+
// Lib1GenericType
434+
exportName = node.qualifier.getText().trim();
435+
} else {
436+
// Example input:
437+
// import('api-extractor-lib1-test')
438+
//
439+
// Extracted qualifier:
440+
// apiExtractorLib1Test
441+
442+
exportName = SyntaxHelpers.makeCamelCaseIdentifier(externalModulePath);
443+
}
444+
445+
return this._fetchAstImport(undefined, {
446+
importKind: AstImportKind.ImportType,
447+
exportName: exportName,
448+
modulePath: externalModulePath,
449+
isTypeOnly: false
450+
});
451+
}
452+
453+
// Internal reference: AstSymbol
454+
const rightMostToken: ts.Identifier | ts.ImportTypeNode = node.qualifier
455+
? node.qualifier.kind === ts.SyntaxKind.QualifiedName
456+
? node.qualifier.right
457+
: node.qualifier
458+
: node;
459+
460+
// There is no symbol property in a ImportTypeNode, obtain the associated export symbol
461+
const exportSymbol: ts.Symbol | undefined = this._typeChecker.getSymbolAtLocation(rightMostToken);
462+
if (!exportSymbol) {
463+
throw new InternalError(
464+
`Symbol not found for identifier: ${node.getText()}\n` +
465+
SourceFileLocationFormatter.formatDeclaration(node)
466+
);
467+
}
468+
469+
let followedSymbol: ts.Symbol = exportSymbol;
470+
for (;;) {
471+
const referencedAstEntity: AstEntity | undefined = this.fetchReferencedAstEntity(
472+
followedSymbol,
473+
referringModuleIsExternal
474+
);
475+
476+
if (referencedAstEntity) {
477+
return referencedAstEntity;
478+
}
479+
480+
const followedSymbolNode: ts.Node | ts.ImportTypeNode | undefined =
481+
followedSymbol.declarations && (followedSymbol.declarations[0] as ts.Node | undefined);
482+
483+
if (followedSymbolNode && followedSymbolNode.kind === ts.SyntaxKind.ImportType) {
484+
return this.fetchReferencedAstEntityFromImportTypeNode(
485+
followedSymbolNode as ts.ImportTypeNode,
486+
referringModuleIsExternal
487+
);
488+
}
489+
490+
// eslint-disable-next-line no-bitwise
491+
if (!(followedSymbol.flags & ts.SymbolFlags.Alias)) {
492+
break;
493+
}
494+
495+
const currentAlias: ts.Symbol = this._typeChecker.getAliasedSymbol(followedSymbol);
496+
if (!currentAlias || currentAlias === followedSymbol) {
497+
break;
498+
}
499+
500+
followedSymbol = currentAlias;
501+
}
502+
503+
const astSymbol: AstSymbol | undefined = this._astSymbolTable.fetchAstSymbol({
504+
followedSymbol: followedSymbol,
505+
isExternal: referringModuleIsExternal,
506+
includeNominalAnalysis: false,
507+
addIfMissing: true
508+
});
509+
510+
return astSymbol;
511+
}
512+
419513
private _tryMatchExportDeclaration(
420514
declaration: ts.Declaration,
421515
declarationSymbol: ts.Symbol
@@ -659,17 +753,6 @@ export class ExportAnalyzer {
659753
}
660754
}
661755

662-
const importTypeNode: ts.Node | undefined = TypeScriptHelpers.findFirstChildNode(
663-
declaration,
664-
ts.SyntaxKind.ImportType
665-
);
666-
if (importTypeNode) {
667-
throw new Error(
668-
'The expression contains an import() type, which is not yet supported by API Extractor:\n' +
669-
SourceFileLocationFormatter.formatDeclaration(importTypeNode)
670-
);
671-
}
672-
673756
return undefined;
674757
}
675758

@@ -769,8 +852,8 @@ export class ExportAnalyzer {
769852
}
770853

771854
private _tryGetExternalModulePath(
772-
importOrExportDeclaration: ts.ImportDeclaration | ts.ExportDeclaration,
773-
exportSymbol: ts.Symbol
855+
importOrExportDeclaration: ts.ImportDeclaration | ts.ExportDeclaration | ts.ImportTypeNode,
856+
exportSymbol?: ts.Symbol
774857
): string | undefined {
775858
// The name of the module, which could be like "./SomeLocalFile' or like 'external-package/entry/point'
776859
const moduleSpecifier: string | undefined =

apps/api-extractor/src/analyzer/StringChecks.ts renamed to apps/api-extractor/src/analyzer/SyntaxHelpers.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import * as ts from 'typescript';
66
/**
77
* Helpers for validating various text string formats.
88
*/
9-
export class StringChecks {
9+
export class SyntaxHelpers {
1010
/**
1111
* Tests whether the input string is safe to use as an ECMAScript identifier without quotes.
1212
*
@@ -41,4 +41,37 @@ export class StringChecks {
4141

4242
return true;
4343
}
44+
45+
/**
46+
* Given an arbitrary input string, return a regular TypeScript identifier name.
47+
*
48+
* @remarks
49+
* Example input: "api-extractor-lib1-test"
50+
* Example output: "apiExtractorLib1Test"
51+
*/
52+
public static makeCamelCaseIdentifier(input: string): string {
53+
const parts: string[] = input.split(/\W+/).filter((x) => x.length > 0);
54+
if (parts.length === 0) {
55+
return '_';
56+
}
57+
58+
for (let i: number = 0; i < parts.length; ++i) {
59+
let part: string = parts[i];
60+
if (part.toUpperCase() === part) {
61+
// Preserve existing case unless the part is all upper-case
62+
part = part.toLowerCase();
63+
}
64+
if (i === 0) {
65+
// If the first part starts with a number, prepend "_"
66+
if (/[0-9]/.test(part.charAt(0))) {
67+
part = '_' + part;
68+
}
69+
} else {
70+
// Capitalize the first letter of each part, except for the first one
71+
part = part.charAt(0).toUpperCase() + part.slice(1);
72+
}
73+
parts[i] = part;
74+
}
75+
return parts.join('');
76+
}
4477
}

0 commit comments

Comments
 (0)