mirror of
https://github.com/discordjs/discord.js.git
synced 2026-03-13 18:13:29 +01:00
build: package api-extractor and -model (#9920)
* fix(ExceptText): don't display import("d..-types/v10"). in return type
* Squashed 'packages/api-extractor-model/' content from commit 39ecb196c
git-subtree-dir: packages/api-extractor-model
git-subtree-split: 39ecb196ca210bdf84ba6c9cadb1bb93571849d7
* Squashed 'packages/api-extractor/' content from commit 341ad6c51
git-subtree-dir: packages/api-extractor
git-subtree-split: 341ad6c51b01656d4f73b74ad4bdb3095f9262c4
* feat(api-extractor): add api-extractor and -model
* fix: package.json docs script
* fix(SourcLink): use <> instead of function syntax
* fix: make packages private
* fix: rest params showing in docs, added labels
* fix: missed two files
* fix: cpy-cli & pnpm-lock
* fix: increase icon size
* fix: icon size again
This commit is contained in:
288
packages/api-extractor/src/analyzer/AstDeclaration.ts
Normal file
288
packages/api-extractor/src/analyzer/AstDeclaration.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
import { InternalError } from '@rushstack/node-core-library';
|
||||
import * as ts from 'typescript';
|
||||
import type { AstEntity } from './AstEntity.js';
|
||||
import type { AstSymbol } from './AstSymbol.js';
|
||||
import { Span } from './Span.js';
|
||||
|
||||
/**
|
||||
* Constructor options for AstDeclaration
|
||||
*/
|
||||
export interface IAstDeclarationOptions {
|
||||
readonly astSymbol: AstSymbol;
|
||||
readonly declaration: ts.Declaration;
|
||||
readonly parent: AstDeclaration | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The AstDeclaration and AstSymbol classes are API Extractor's equivalent of the compiler's
|
||||
* ts.Declaration and ts.Symbol objects. They are created by the `AstSymbolTable` class.
|
||||
*
|
||||
* @remarks
|
||||
* The AstDeclaration represents one or more syntax components of a symbol. Usually there is
|
||||
* only one AstDeclaration per AstSymbol, but certain TypeScript constructs can have multiple
|
||||
* declarations (e.g. overloaded functions, merged declarations, etc.).
|
||||
*
|
||||
* Because of this, the `AstDeclaration` manages the parent/child nesting hierarchy (e.g. with
|
||||
* declaration merging, each declaration has its own children) and becomes the main focus
|
||||
* of analyzing AEDoc and emitting *.d.ts files.
|
||||
*
|
||||
* The AstDeclarations correspond to items from the compiler's ts.Node hierarchy, but
|
||||
* omitting/skipping any nodes that don't match the AstDeclaration.isSupportedSyntaxKind()
|
||||
* criteria. This simplification makes the other API Extractor stages easier to implement.
|
||||
*/
|
||||
export class AstDeclaration {
|
||||
public readonly declaration: ts.Declaration;
|
||||
|
||||
public readonly astSymbol: AstSymbol;
|
||||
|
||||
/**
|
||||
* The parent, if this object is nested inside another AstDeclaration.
|
||||
*/
|
||||
public readonly parent: AstDeclaration | undefined;
|
||||
|
||||
/**
|
||||
* A bit set of TypeScript modifiers such as "private", "protected", etc.
|
||||
*/
|
||||
public readonly modifierFlags: ts.ModifierFlags;
|
||||
|
||||
/**
|
||||
* Additional information that is calculated later by the `Collector`. The actual type is `DeclarationMetadata`,
|
||||
* but we declare it as `unknown` because consumers must obtain this object by calling
|
||||
* `Collector.fetchDeclarationMetadata()`.
|
||||
*/
|
||||
public declarationMetadata: unknown;
|
||||
|
||||
/**
|
||||
* Additional information that is calculated later by the `Collector`. The actual type is `ApiItemMetadata`,
|
||||
* but we declare it as `unknown` because consumers must obtain this object by calling
|
||||
* `Collector.fetchApiItemMetadata()`.
|
||||
*/
|
||||
public apiItemMetadata: unknown;
|
||||
|
||||
// NOTE: This array becomes immutable after astSymbol.analyze() sets astSymbol.analyzed=true
|
||||
private readonly _analyzedChildren: AstDeclaration[] = [];
|
||||
|
||||
private readonly _analyzedReferencedAstEntitiesSet: Set<AstEntity> = new Set<AstEntity>();
|
||||
|
||||
// Reverse lookup used by findChildrenWithName()
|
||||
private _childrenByName: Map<string, AstDeclaration[]> | undefined = undefined;
|
||||
|
||||
public constructor(options: IAstDeclarationOptions) {
|
||||
this.declaration = options.declaration;
|
||||
this.astSymbol = options.astSymbol;
|
||||
this.parent = options.parent;
|
||||
|
||||
this.astSymbol._notifyDeclarationAttach(this);
|
||||
|
||||
if (this.parent) {
|
||||
this.parent._notifyChildAttach(this);
|
||||
}
|
||||
|
||||
this.modifierFlags = ts.getCombinedModifierFlags(this.declaration);
|
||||
|
||||
// Check for ECMAScript private fields, for example:
|
||||
//
|
||||
// class Person { #name: string; }
|
||||
//
|
||||
const declarationName: ts.DeclarationName | undefined = ts.getNameOfDeclaration(this.declaration);
|
||||
if (declarationName && ts.isPrivateIdentifier(declarationName)) {
|
||||
this.modifierFlags |= ts.ModifierFlags.Private;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the children for this AstDeclaration.
|
||||
*
|
||||
* @remarks
|
||||
* The collection will be empty until AstSymbol.analyzed is true.
|
||||
*/
|
||||
public get children(): readonly AstDeclaration[] {
|
||||
return this.astSymbol.analyzed ? this._analyzedChildren : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the AstEntity objects referenced by this node.
|
||||
*
|
||||
* @remarks
|
||||
* NOTE: The collection will be empty until AstSymbol.analyzed is true.
|
||||
*
|
||||
* Since we assume references are always collected by a traversal starting at the
|
||||
* root of the nesting declarations, this array omits the following items because they
|
||||
* would be redundant:
|
||||
* - symbols corresponding to parents of this declaration (e.g. a method that returns its own class)
|
||||
* - symbols already listed in the referencedAstSymbols property for parents of this declaration
|
||||
* (e.g. a method that returns its own class's base class)
|
||||
* - symbols that are referenced only by nested children of this declaration
|
||||
* (e.g. if a method returns an enum, this doesn't imply that the method's class references that enum)
|
||||
*/
|
||||
public get referencedAstEntities(): readonly AstEntity[] {
|
||||
return this.astSymbol.analyzed ? [...this._analyzedReferencedAstEntitiesSet] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* This is an internal callback used when the AstSymbolTable attaches a new
|
||||
* child AstDeclaration to this object.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public _notifyChildAttach(child: AstDeclaration): void {
|
||||
if (child.parent !== this) {
|
||||
throw new InternalError('Invalid call to notifyChildAttach()');
|
||||
}
|
||||
|
||||
if (this.astSymbol.analyzed) {
|
||||
throw new InternalError('_notifyChildAttach() called after analysis is already complete');
|
||||
}
|
||||
|
||||
this._analyzedChildren.push(child);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a diagnostic dump of the tree, which reports the hierarchy of
|
||||
* AstDefinition objects.
|
||||
*/
|
||||
public getDump(indent: string = ''): string {
|
||||
const declarationKind: string = ts.SyntaxKind[this.declaration.kind];
|
||||
let result: string = indent + `+ ${this.astSymbol.localName} (${declarationKind})`;
|
||||
if (this.astSymbol.nominalAnalysis) {
|
||||
result += ' (nominal)';
|
||||
}
|
||||
|
||||
result += '\n';
|
||||
|
||||
for (const referencedAstEntity of this._analyzedReferencedAstEntitiesSet.values()) {
|
||||
result += indent + ` ref: ${referencedAstEntity.localName}\n`;
|
||||
}
|
||||
|
||||
for (const child of this.children) {
|
||||
result += child.getDump(indent + ' ');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a diagnostic dump using Span.getDump(), which reports the detailed
|
||||
* compiler structure.
|
||||
*/
|
||||
public getSpanDump(indent: string = ''): string {
|
||||
const span: Span = new Span(this.declaration);
|
||||
return span.getDump(indent);
|
||||
}
|
||||
|
||||
/**
|
||||
* This is an internal callback used when AstSymbolTable.analyze() discovers a new
|
||||
* type reference associated with this declaration.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public _notifyReferencedAstEntity(referencedAstEntity: AstEntity): void {
|
||||
if (this.astSymbol.analyzed) {
|
||||
throw new InternalError('_notifyReferencedAstEntity() called after analysis is already complete');
|
||||
}
|
||||
|
||||
for (let current: AstDeclaration | undefined = this; current; current = current.parent) {
|
||||
// Don't add references to symbols that are already referenced by a parent
|
||||
if (current._analyzedReferencedAstEntitiesSet.has(referencedAstEntity)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't add the symbols of parents either
|
||||
if (referencedAstEntity === current.astSymbol) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this._analyzedReferencedAstEntitiesSet.add(referencedAstEntity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Visits all the current declaration and all children recursively in a depth-first traversal,
|
||||
* and performs the specified action for each one.
|
||||
*/
|
||||
public forEachDeclarationRecursive(action: (astDeclaration: AstDeclaration) => void): void {
|
||||
action(this);
|
||||
for (const child of this.children) {
|
||||
child.forEachDeclarationRecursive(action);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of child declarations whose `AstSymbol.localName` matches the provided `name`.
|
||||
*
|
||||
* @remarks
|
||||
* This is an efficient O(1) lookup.
|
||||
*/
|
||||
public findChildrenWithName(name: string): readonly AstDeclaration[] {
|
||||
// The children property returns:
|
||||
//
|
||||
// return this.astSymbol.analyzed ? this._analyzedChildren : [];
|
||||
//
|
||||
if (!this.astSymbol.analyzed || this._analyzedChildren.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (this._childrenByName === undefined) {
|
||||
// Build the lookup table
|
||||
const childrenByName: Map<string, AstDeclaration[]> = new Map<string, AstDeclaration[]>();
|
||||
|
||||
for (const child of this._analyzedChildren) {
|
||||
const childName: string = child.astSymbol.localName;
|
||||
let array: AstDeclaration[] | undefined = childrenByName.get(childName);
|
||||
if (array === undefined) {
|
||||
array = [];
|
||||
childrenByName.set(childName, array);
|
||||
}
|
||||
|
||||
array.push(child);
|
||||
}
|
||||
|
||||
this._childrenByName = childrenByName;
|
||||
}
|
||||
|
||||
return this._childrenByName.get(name) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* This function determines which ts.Node kinds will generate an AstDeclaration.
|
||||
* These correspond to the definitions that we can add AEDoc to.
|
||||
*/
|
||||
public static isSupportedSyntaxKind(kind: ts.SyntaxKind): boolean {
|
||||
// (alphabetical order)
|
||||
switch (kind) {
|
||||
case ts.SyntaxKind.CallSignature:
|
||||
case ts.SyntaxKind.ClassDeclaration:
|
||||
case ts.SyntaxKind.ConstructSignature: // Example: "new(x: number): IMyClass"
|
||||
case ts.SyntaxKind.Constructor: // Example: "constructor(x: number)"
|
||||
case ts.SyntaxKind.EnumDeclaration:
|
||||
case ts.SyntaxKind.EnumMember:
|
||||
case ts.SyntaxKind.FunctionDeclaration: // Example: "(x: number): number"
|
||||
case ts.SyntaxKind.GetAccessor:
|
||||
case ts.SyntaxKind.SetAccessor:
|
||||
case ts.SyntaxKind.IndexSignature: // Example: "[key: string]: string"
|
||||
case ts.SyntaxKind.InterfaceDeclaration:
|
||||
case ts.SyntaxKind.MethodDeclaration:
|
||||
case ts.SyntaxKind.MethodSignature:
|
||||
case ts.SyntaxKind.ModuleDeclaration: // Used for both "module" and "namespace" declarations
|
||||
case ts.SyntaxKind.PropertyDeclaration:
|
||||
case ts.SyntaxKind.PropertySignature:
|
||||
case ts.SyntaxKind.TypeAliasDeclaration: // Example: "type Shape = Circle | Square"
|
||||
case ts.SyntaxKind.VariableDeclaration:
|
||||
return true;
|
||||
|
||||
// NOTE: Prior to TypeScript 3.7, in the emitted .d.ts files, the compiler would merge a GetAccessor/SetAccessor
|
||||
// pair into a single PropertyDeclaration.
|
||||
|
||||
// NOTE: In contexts where a source file is treated as a module, we do create "nominal analysis"
|
||||
// AstSymbol objects corresponding to a ts.SyntaxKind.SourceFile node. However, a source file
|
||||
// is NOT considered a nesting structure, and it does NOT act as a root for the declarations
|
||||
// appearing in the file. This is because the *.d.ts generator is in the business of rolling up
|
||||
// source files, and thus wants to ignore them in general.
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
46
packages/api-extractor/src/analyzer/AstEntity.ts
Normal file
46
packages/api-extractor/src/analyzer/AstEntity.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
/**
|
||||
* `AstEntity` is the abstract base class for analyzer objects that can become a `CollectorEntity`.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* The subclasses are:
|
||||
* ```
|
||||
* - AstEntity
|
||||
* - AstSymbol
|
||||
* - AstSyntheticEntity
|
||||
* - AstImport
|
||||
* - AstNamespaceImport
|
||||
* ```
|
||||
*/
|
||||
export abstract class AstEntity {
|
||||
/**
|
||||
* The original name of the symbol, as exported from the module (i.e. source file)
|
||||
* containing the original TypeScript definition. Constructs such as
|
||||
* `import { X as Y } from` may introduce other names that differ from the local name.
|
||||
*
|
||||
* @remarks
|
||||
* For the most part, `localName` corresponds to `followedSymbol.name`, but there
|
||||
* are some edge cases. For example, the ts.Symbol.name for `export default class X { }`
|
||||
* is actually `"default"`, not `"X"`.
|
||||
*/
|
||||
public abstract readonly localName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* `AstSyntheticEntity` is the abstract base class for analyzer objects whose emitted declarations
|
||||
* are not text transformations performed by the `Span` helper.
|
||||
*
|
||||
* @remarks
|
||||
* Most of API Extractor's output is produced by using the using the `Span` utility to regurgitate strings from
|
||||
* the input .d.ts files. If we need to rename an identifier, the `Span` visitor can pick out an interesting
|
||||
* node and rewrite its string, but otherwise the transformation operates on dumb text and not compiler concepts.
|
||||
* (Historically we did this because the compiler's emitter was an internal API, but it still has some advantages,
|
||||
* for example preserving syntaxes generated by an older compiler to avoid incompatibilities.)
|
||||
*
|
||||
* This strategy does not work for cases where the output looks very different from the input. Today these
|
||||
* cases are always kinds of `import` statements, but that may change in the future.
|
||||
*/
|
||||
export abstract class AstSyntheticEntity extends AstEntity {}
|
||||
168
packages/api-extractor/src/analyzer/AstImport.ts
Normal file
168
packages/api-extractor/src/analyzer/AstImport.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
import { InternalError } from '@rushstack/node-core-library';
|
||||
import { AstSyntheticEntity } from './AstEntity.js';
|
||||
import type { AstSymbol } from './AstSymbol.js';
|
||||
|
||||
/**
|
||||
* Indicates the import kind for an `AstImport`.
|
||||
*/
|
||||
export enum AstImportKind {
|
||||
/**
|
||||
* An import statement such as `import X from "y";`.
|
||||
*/
|
||||
DefaultImport,
|
||||
|
||||
/**
|
||||
* An import statement such as `import { X } from "y";`.
|
||||
*/
|
||||
NamedImport,
|
||||
|
||||
/**
|
||||
* An import statement such as `import * as x from "y";`.
|
||||
*/
|
||||
StarImport,
|
||||
|
||||
/**
|
||||
* An import statement such as `import x = require("y");`.
|
||||
*/
|
||||
EqualsImport,
|
||||
|
||||
/**
|
||||
* An import statement such as `interface foo { foo: import("bar").a.b.c }`.
|
||||
*/
|
||||
ImportType,
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor parameters for AstImport
|
||||
*
|
||||
* @privateRemarks
|
||||
* Our naming convention is to use I____Parameters for constructor options and
|
||||
* I____Options for general function options. However the word "parameters" is
|
||||
* confusingly similar to the terminology for function parameters modeled by API Extractor,
|
||||
* so we use I____Options for both cases in this code base.
|
||||
*/
|
||||
export interface IAstImportOptions {
|
||||
readonly exportName: string;
|
||||
readonly importKind: AstImportKind;
|
||||
readonly isTypeOnly: boolean;
|
||||
readonly modulePath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* For a symbol that was imported from an external package, this tracks the import
|
||||
* statement that was used to reach it.
|
||||
*/
|
||||
export class AstImport extends AstSyntheticEntity {
|
||||
public readonly importKind: AstImportKind;
|
||||
|
||||
/**
|
||||
* The name of the external package (and possibly module path) that this definition
|
||||
* was imported from.
|
||||
*
|
||||
* Example: "\@rushstack/node-core-library/lib/FileSystem"
|
||||
*/
|
||||
public readonly modulePath: string;
|
||||
|
||||
/**
|
||||
* The name of the symbol being imported.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* The name depends on the type of import:
|
||||
*
|
||||
* ```ts
|
||||
* // For AstImportKind.DefaultImport style, exportName would be "X" in this example:
|
||||
* import X from "y";
|
||||
*
|
||||
* // For AstImportKind.NamedImport style, exportName would be "X" in this example:
|
||||
* import { X } from "y";
|
||||
*
|
||||
* // For AstImportKind.StarImport style, exportName would be "x" in this example:
|
||||
* import * as x from "y";
|
||||
*
|
||||
* // For AstImportKind.EqualsImport style, exportName would be "x" in this example:
|
||||
* import x = require("y");
|
||||
*
|
||||
* // For AstImportKind.ImportType style, exportName would be "a.b.c" in this example:
|
||||
* interface foo { foo: import('bar').a.b.c };
|
||||
* ```
|
||||
*/
|
||||
public readonly exportName: string;
|
||||
|
||||
/**
|
||||
* Whether it is a type-only import, for example:
|
||||
*
|
||||
* ```ts
|
||||
* import type { X } from "y";
|
||||
* ```
|
||||
*
|
||||
* This is set to true ONLY if the type-only form is used in *every* reference to this AstImport.
|
||||
*/
|
||||
public isTypeOnlyEverywhere: boolean;
|
||||
|
||||
/**
|
||||
* If this import statement refers to an API from an external package that is tracked by API Extractor
|
||||
* (according to `PackageMetadataManager.isAedocSupportedFor()`), then this property will return the
|
||||
* corresponding AstSymbol. Otherwise, it is undefined.
|
||||
*/
|
||||
public astSymbol: AstSymbol | undefined;
|
||||
|
||||
/**
|
||||
* If modulePath and exportName are defined, then this is a dictionary key
|
||||
* that combines them with a colon (":").
|
||||
*
|
||||
* Example: "\@rushstack/node-core-library/lib/FileSystem:FileSystem"
|
||||
*/
|
||||
public readonly key: string;
|
||||
|
||||
public constructor(options: IAstImportOptions) {
|
||||
super();
|
||||
|
||||
this.importKind = options.importKind;
|
||||
this.modulePath = options.modulePath;
|
||||
this.exportName = options.exportName;
|
||||
|
||||
// We start with this assumption, but it may get changed later if non-type-only import is encountered.
|
||||
this.isTypeOnlyEverywhere = options.isTypeOnly;
|
||||
|
||||
this.key = AstImport.getKey(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public get localName(): string {
|
||||
// abstract
|
||||
return this.exportName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the lookup key used with `AstImport.key`
|
||||
*/
|
||||
public static getKey(options: IAstImportOptions): string {
|
||||
switch (options.importKind) {
|
||||
case AstImportKind.DefaultImport:
|
||||
return `${options.modulePath}:${options.exportName}`;
|
||||
case AstImportKind.NamedImport:
|
||||
return `${options.modulePath}:${options.exportName}`;
|
||||
case AstImportKind.StarImport:
|
||||
return `${options.modulePath}:*`;
|
||||
case AstImportKind.EqualsImport:
|
||||
return `${options.modulePath}:=`;
|
||||
case AstImportKind.ImportType: {
|
||||
const subKey: string = options.exportName
|
||||
? options.exportName.includes('.') // Equivalent to a named export
|
||||
? options.exportName.split('.')[0]!
|
||||
: options.exportName
|
||||
: '*';
|
||||
return `${options.modulePath}:${subKey}`;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new InternalError('Unknown AstImportKind');
|
||||
}
|
||||
}
|
||||
}
|
||||
88
packages/api-extractor/src/analyzer/AstModule.ts
Normal file
88
packages/api-extractor/src/analyzer/AstModule.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
import type * as ts from 'typescript';
|
||||
import type { AstEntity } from './AstEntity.js';
|
||||
import type { AstSymbol } from './AstSymbol.js';
|
||||
|
||||
/**
|
||||
* Represents information collected by {@link AstSymbolTable.fetchAstModuleExportInfo}
|
||||
*/
|
||||
export class AstModuleExportInfo {
|
||||
public readonly exportedLocalEntities: Map<string, AstEntity> = new Map<string, AstEntity>();
|
||||
|
||||
public readonly starExportedExternalModules: Set<AstModule> = new Set<AstModule>();
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor parameters for AstModule
|
||||
*
|
||||
* @privateRemarks
|
||||
* Our naming convention is to use I____Parameters for constructor options and
|
||||
* I____Options for general function options. However the word "parameters" is
|
||||
* confusingly similar to the terminology for function parameters modeled by API Extractor,
|
||||
* so we use I____Options for both cases in this code base.
|
||||
*/
|
||||
export interface IAstModuleOptions {
|
||||
externalModulePath: string | undefined;
|
||||
moduleSymbol: ts.Symbol;
|
||||
sourceFile: ts.SourceFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* An internal data structure that represents a source file that is analyzed by AstSymbolTable.
|
||||
*/
|
||||
export class AstModule {
|
||||
/**
|
||||
* The source file that declares this TypeScript module. In most cases, the source file's
|
||||
* top-level exports constitute the module.
|
||||
*/
|
||||
public readonly sourceFile: ts.SourceFile;
|
||||
|
||||
/**
|
||||
* The symbol for the module. Typically this corresponds to ts.SourceFile itself, however
|
||||
* in some cases the ts.SourceFile may contain multiple modules declared using the `module` keyword.
|
||||
*/
|
||||
public readonly moduleSymbol: ts.Symbol;
|
||||
|
||||
/**
|
||||
* Example: "\@rushstack/node-core-library/lib/FileSystem"
|
||||
* but never: "./FileSystem"
|
||||
*/
|
||||
public readonly externalModulePath: string | undefined;
|
||||
|
||||
/**
|
||||
* A list of other `AstModule` objects that appear in `export * from "___";` statements.
|
||||
*/
|
||||
public readonly starExportedModules: Set<AstModule>;
|
||||
|
||||
/**
|
||||
* A partial map of entities exported by this module. The key is the exported name.
|
||||
*/
|
||||
public readonly cachedExportedEntities: Map<string, AstEntity>; // exportName --> entity
|
||||
|
||||
/**
|
||||
* Additional state calculated by `AstSymbolTable.fetchWorkingPackageModule()`.
|
||||
*/
|
||||
public astModuleExportInfo: AstModuleExportInfo | undefined;
|
||||
|
||||
public constructor(options: IAstModuleOptions) {
|
||||
this.sourceFile = options.sourceFile;
|
||||
this.moduleSymbol = options.moduleSymbol;
|
||||
|
||||
this.externalModulePath = options.externalModulePath;
|
||||
|
||||
this.starExportedModules = new Set<AstModule>();
|
||||
|
||||
this.cachedExportedEntities = new Map<string, AstSymbol>();
|
||||
|
||||
this.astModuleExportInfo = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* If false, then this source file is part of the working package being processed by the `Collector`.
|
||||
*/
|
||||
public get isExternal(): boolean {
|
||||
return this.externalModulePath !== undefined;
|
||||
}
|
||||
}
|
||||
95
packages/api-extractor/src/analyzer/AstNamespaceImport.ts
Normal file
95
packages/api-extractor/src/analyzer/AstNamespaceImport.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
import type * as ts from 'typescript';
|
||||
import type { Collector } from '../collector/Collector.js';
|
||||
import { AstSyntheticEntity } from './AstEntity.js';
|
||||
import type { AstModule, AstModuleExportInfo } from './AstModule.js';
|
||||
|
||||
export interface IAstNamespaceImportOptions {
|
||||
readonly astModule: AstModule;
|
||||
readonly declaration: ts.Declaration;
|
||||
readonly namespaceName: string;
|
||||
readonly symbol: ts.Symbol;
|
||||
}
|
||||
|
||||
/**
|
||||
* `AstNamespaceImport` represents a namespace that is created implicitly by a statement
|
||||
* such as `import * as example from "./file";`
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* A typical input looks like this:
|
||||
* ```ts
|
||||
* // Suppose that example.ts exports two functions f1() and f2().
|
||||
* import * as example from "./file";
|
||||
* export { example };
|
||||
* ```
|
||||
*
|
||||
* API Extractor's .d.ts rollup will transform it into an explicit namespace, like this:
|
||||
* ```ts
|
||||
* declare f1(): void;
|
||||
* declare f2(): void;
|
||||
*
|
||||
* declare namespace example {
|
||||
* export {
|
||||
* f1,
|
||||
* f2
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* The current implementation does not attempt to relocate f1()/f2() to be inside the `namespace`
|
||||
* because other type signatures may reference them directly (without using the namespace qualifier).
|
||||
* The `declare namespace example` is a synthetic construct represented by `AstNamespaceImport`.
|
||||
*/
|
||||
export class AstNamespaceImport extends AstSyntheticEntity {
|
||||
/**
|
||||
* Returns true if the AstSymbolTable.analyze() was called for this object.
|
||||
* See that function for details.
|
||||
*/
|
||||
public analyzed: boolean = false;
|
||||
|
||||
/**
|
||||
* For example, if the original statement was `import * as example from "./file";`
|
||||
* then `astModule` refers to the `./file.d.ts` file.
|
||||
*/
|
||||
public readonly astModule: AstModule;
|
||||
|
||||
/**
|
||||
* For example, if the original statement was `import * as example from "./file";`
|
||||
* then `namespaceName` would be `example`.
|
||||
*/
|
||||
public readonly namespaceName: string;
|
||||
|
||||
/**
|
||||
* The original `ts.SyntaxKind.NamespaceImport` which can be used as a location for error messages.
|
||||
*/
|
||||
public readonly declaration: ts.Declaration;
|
||||
|
||||
/**
|
||||
* The original `ts.SymbolFlags.Namespace` symbol.
|
||||
*/
|
||||
public readonly symbol: ts.Symbol;
|
||||
|
||||
public constructor(options: IAstNamespaceImportOptions) {
|
||||
super();
|
||||
this.astModule = options.astModule;
|
||||
this.namespaceName = options.namespaceName;
|
||||
this.declaration = options.declaration;
|
||||
this.symbol = options.symbol;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public get localName(): string {
|
||||
// abstract
|
||||
return this.namespaceName;
|
||||
}
|
||||
|
||||
public fetchAstModuleExportInfo(collector: Collector): AstModuleExportInfo {
|
||||
const astModuleExportInfo: AstModuleExportInfo = collector.astSymbolTable.fetchAstModuleExportInfo(this.astModule);
|
||||
return astModuleExportInfo;
|
||||
}
|
||||
}
|
||||
296
packages/api-extractor/src/analyzer/AstReferenceResolver.ts
Normal file
296
packages/api-extractor/src/analyzer/AstReferenceResolver.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
import * as tsdoc from '@microsoft/tsdoc';
|
||||
import * as ts from 'typescript';
|
||||
import type { Collector } from '../collector/Collector.js';
|
||||
import type { DeclarationMetadata } from '../collector/DeclarationMetadata.js';
|
||||
import type { WorkingPackage } from '../collector/WorkingPackage.js';
|
||||
import type { AstDeclaration } from './AstDeclaration.js';
|
||||
import type { AstEntity } from './AstEntity.js';
|
||||
import type { AstModule } from './AstModule.js';
|
||||
import { AstSymbol } from './AstSymbol.js';
|
||||
import type { AstSymbolTable } from './AstSymbolTable.js';
|
||||
|
||||
/**
|
||||
* Used by `AstReferenceResolver` to report a failed resolution.
|
||||
*
|
||||
* @privateRemarks
|
||||
* This class is similar to an `Error` object, but the intent of `ResolverFailure` is to describe
|
||||
* why a reference could not be resolved. This information could be used to throw an actual `Error` object,
|
||||
* but normally it is handed off to the `MessageRouter` instead.
|
||||
*/
|
||||
export class ResolverFailure {
|
||||
/**
|
||||
* Details about why the failure occurred.
|
||||
*/
|
||||
public readonly reason: string;
|
||||
|
||||
public constructor(reason: string) {
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This resolves a TSDoc declaration reference by walking the `AstSymbolTable` compiler state.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* This class is analogous to `ModelReferenceResolver` from the `@microsoft/api-extractor-model` project,
|
||||
* which resolves declaration references by walking the hierarchy loaded from an .api.json file.
|
||||
*/
|
||||
export class AstReferenceResolver {
|
||||
private readonly _collector: Collector;
|
||||
|
||||
private readonly _astSymbolTable: AstSymbolTable;
|
||||
|
||||
private readonly _workingPackage: WorkingPackage;
|
||||
|
||||
public constructor(collector: Collector) {
|
||||
this._collector = collector;
|
||||
this._astSymbolTable = collector.astSymbolTable;
|
||||
this._workingPackage = collector.workingPackage;
|
||||
}
|
||||
|
||||
public resolve(declarationReference: tsdoc.DocDeclarationReference): AstDeclaration | ResolverFailure {
|
||||
// Is it referring to the working package?
|
||||
if (
|
||||
declarationReference.packageName !== undefined &&
|
||||
declarationReference.packageName !== this._workingPackage.name
|
||||
) {
|
||||
return new ResolverFailure('External package references are not supported');
|
||||
}
|
||||
|
||||
// Is it a path-based import?
|
||||
if (declarationReference.importPath) {
|
||||
return new ResolverFailure('Import paths are not supported');
|
||||
}
|
||||
|
||||
const astModule: AstModule = this._astSymbolTable.fetchAstModuleFromWorkingPackage(
|
||||
this._workingPackage.entryPointSourceFile,
|
||||
);
|
||||
|
||||
if (declarationReference.memberReferences.length === 0) {
|
||||
return new ResolverFailure('Package references are not supported');
|
||||
}
|
||||
|
||||
const rootMemberReference: tsdoc.DocMemberReference = declarationReference.memberReferences[0]!;
|
||||
|
||||
const exportName: ResolverFailure | string = this._getMemberReferenceIdentifier(rootMemberReference);
|
||||
if (exportName instanceof ResolverFailure) {
|
||||
return exportName;
|
||||
}
|
||||
|
||||
const rootAstEntity: AstEntity | undefined = this._astSymbolTable.tryGetExportOfAstModule(exportName, astModule);
|
||||
|
||||
if (rootAstEntity === undefined) {
|
||||
return new ResolverFailure(`The package "${this._workingPackage.name}" does not have an export "${exportName}"`);
|
||||
}
|
||||
|
||||
if (!(rootAstEntity instanceof AstSymbol)) {
|
||||
return new ResolverFailure('This type of declaration is not supported yet by the resolver');
|
||||
}
|
||||
|
||||
let currentDeclaration: AstDeclaration | ResolverFailure = this._selectDeclaration(
|
||||
rootAstEntity.astDeclarations,
|
||||
rootMemberReference,
|
||||
rootAstEntity.localName,
|
||||
);
|
||||
|
||||
if (currentDeclaration instanceof ResolverFailure) {
|
||||
return currentDeclaration;
|
||||
}
|
||||
|
||||
for (let index = 1; index < declarationReference.memberReferences.length; ++index) {
|
||||
const memberReference: tsdoc.DocMemberReference = declarationReference.memberReferences[index]!;
|
||||
|
||||
const memberName: ResolverFailure | string = this._getMemberReferenceIdentifier(memberReference);
|
||||
if (memberName instanceof ResolverFailure) {
|
||||
return memberName;
|
||||
}
|
||||
|
||||
const matchingChildren: readonly AstDeclaration[] = currentDeclaration.findChildrenWithName(memberName);
|
||||
if (matchingChildren.length === 0) {
|
||||
return new ResolverFailure(`No member was found with name "${memberName}"`);
|
||||
}
|
||||
|
||||
const selectedDeclaration: AstDeclaration | ResolverFailure = this._selectDeclaration(
|
||||
matchingChildren,
|
||||
memberReference,
|
||||
memberName,
|
||||
);
|
||||
|
||||
if (selectedDeclaration instanceof ResolverFailure) {
|
||||
return selectedDeclaration;
|
||||
}
|
||||
|
||||
currentDeclaration = selectedDeclaration;
|
||||
}
|
||||
|
||||
return currentDeclaration;
|
||||
}
|
||||
|
||||
private _getMemberReferenceIdentifier(memberReference: tsdoc.DocMemberReference): ResolverFailure | string {
|
||||
if (memberReference.memberSymbol !== undefined) {
|
||||
return new ResolverFailure('ECMAScript symbol selectors are not supported');
|
||||
}
|
||||
|
||||
if (memberReference.memberIdentifier === undefined) {
|
||||
return new ResolverFailure('The member identifier is missing in the root member reference');
|
||||
}
|
||||
|
||||
return memberReference.memberIdentifier.identifier;
|
||||
}
|
||||
|
||||
private _selectDeclaration(
|
||||
astDeclarations: readonly AstDeclaration[],
|
||||
memberReference: tsdoc.DocMemberReference,
|
||||
astSymbolName: string,
|
||||
): AstDeclaration | ResolverFailure {
|
||||
const memberSelector: tsdoc.DocMemberSelector | undefined = memberReference.selector;
|
||||
|
||||
if (memberSelector === undefined) {
|
||||
if (astDeclarations.length === 1) {
|
||||
return astDeclarations[0]!;
|
||||
} else {
|
||||
// If we found multiple matches, but the extra ones are all ancillary declarations,
|
||||
// then return the main declaration.
|
||||
const nonAncillaryMatch: AstDeclaration | undefined = this._tryDisambiguateAncillaryMatches(astDeclarations);
|
||||
if (nonAncillaryMatch) {
|
||||
return nonAncillaryMatch;
|
||||
}
|
||||
|
||||
return new ResolverFailure(
|
||||
`The reference is ambiguous because "${astSymbolName}"` +
|
||||
` has more than one declaration; you need to add a TSDoc member reference selector`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
switch (memberSelector.selectorKind) {
|
||||
case tsdoc.SelectorKind.System:
|
||||
return this._selectUsingSystemSelector(astDeclarations, memberSelector, astSymbolName);
|
||||
case tsdoc.SelectorKind.Index:
|
||||
return this._selectUsingIndexSelector(astDeclarations, memberSelector, astSymbolName);
|
||||
default:
|
||||
return new ResolverFailure(`The selector "${memberSelector.selector}" is not a supported selector type`);
|
||||
}
|
||||
}
|
||||
|
||||
private _selectUsingSystemSelector(
|
||||
astDeclarations: readonly AstDeclaration[],
|
||||
memberSelector: tsdoc.DocMemberSelector,
|
||||
astSymbolName: string,
|
||||
): AstDeclaration | ResolverFailure {
|
||||
const selectorName: string = memberSelector.selector;
|
||||
|
||||
let selectorSyntaxKind: ts.SyntaxKind;
|
||||
|
||||
switch (selectorName) {
|
||||
case 'class':
|
||||
selectorSyntaxKind = ts.SyntaxKind.ClassDeclaration;
|
||||
break;
|
||||
case 'enum':
|
||||
selectorSyntaxKind = ts.SyntaxKind.EnumDeclaration;
|
||||
break;
|
||||
case 'function':
|
||||
selectorSyntaxKind = ts.SyntaxKind.FunctionDeclaration;
|
||||
break;
|
||||
case 'interface':
|
||||
selectorSyntaxKind = ts.SyntaxKind.InterfaceDeclaration;
|
||||
break;
|
||||
case 'namespace':
|
||||
selectorSyntaxKind = ts.SyntaxKind.ModuleDeclaration;
|
||||
break;
|
||||
case 'type':
|
||||
selectorSyntaxKind = ts.SyntaxKind.TypeAliasDeclaration;
|
||||
break;
|
||||
case 'variable':
|
||||
selectorSyntaxKind = ts.SyntaxKind.VariableDeclaration;
|
||||
break;
|
||||
default:
|
||||
return new ResolverFailure(`Unsupported system selector "${selectorName}"`);
|
||||
}
|
||||
|
||||
const matches: AstDeclaration[] = astDeclarations.filter((x) => x.declaration.kind === selectorSyntaxKind);
|
||||
if (matches.length === 0) {
|
||||
return new ResolverFailure(
|
||||
`A declaration for "${astSymbolName}" was not found that matches the TSDoc selector "${selectorName}"`,
|
||||
);
|
||||
}
|
||||
|
||||
if (matches.length > 1) {
|
||||
// If we found multiple matches, but the extra ones are all ancillary declarations,
|
||||
// then return the main declaration.
|
||||
const nonAncillaryMatch: AstDeclaration | undefined = this._tryDisambiguateAncillaryMatches(matches);
|
||||
if (nonAncillaryMatch) {
|
||||
return nonAncillaryMatch;
|
||||
}
|
||||
|
||||
return new ResolverFailure(
|
||||
`More than one declaration "${astSymbolName}" matches the TSDoc selector "${selectorName}"`,
|
||||
);
|
||||
}
|
||||
|
||||
return matches[0]!;
|
||||
}
|
||||
|
||||
private _selectUsingIndexSelector(
|
||||
astDeclarations: readonly AstDeclaration[],
|
||||
memberSelector: tsdoc.DocMemberSelector,
|
||||
astSymbolName: string,
|
||||
): AstDeclaration | ResolverFailure {
|
||||
const selectorOverloadIndex: number = Number.parseInt(memberSelector.selector, 10);
|
||||
|
||||
const matches: AstDeclaration[] = [];
|
||||
for (const astDeclaration of astDeclarations) {
|
||||
const overloadIndex: number = this._collector.getOverloadIndex(astDeclaration);
|
||||
if (overloadIndex === selectorOverloadIndex) {
|
||||
matches.push(astDeclaration);
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length === 0) {
|
||||
return new ResolverFailure(
|
||||
`An overload for "${astSymbolName}" was not found that matches the` +
|
||||
` TSDoc selector ":${selectorOverloadIndex}"`,
|
||||
);
|
||||
}
|
||||
|
||||
if (matches.length > 1) {
|
||||
// If we found multiple matches, but the extra ones are all ancillary declarations,
|
||||
// then return the main declaration.
|
||||
const nonAncillaryMatch: AstDeclaration | undefined = this._tryDisambiguateAncillaryMatches(matches);
|
||||
if (nonAncillaryMatch) {
|
||||
return nonAncillaryMatch;
|
||||
}
|
||||
|
||||
return new ResolverFailure(
|
||||
`More than one declaration for "${astSymbolName}" matches the TSDoc selector ":${selectorOverloadIndex}"`,
|
||||
);
|
||||
}
|
||||
|
||||
return matches[0]!;
|
||||
}
|
||||
|
||||
/**
|
||||
* This resolves an ambiguous match in the case where the extra matches are all ancillary declarations,
|
||||
* except for one match that is the main declaration.
|
||||
*/
|
||||
private _tryDisambiguateAncillaryMatches(matches: readonly AstDeclaration[]): AstDeclaration | undefined {
|
||||
let result: AstDeclaration | undefined;
|
||||
|
||||
for (const match of matches) {
|
||||
const declarationMetadata: DeclarationMetadata = this._collector.fetchDeclarationMetadata(match);
|
||||
if (!declarationMetadata.isAncillary) {
|
||||
if (result) {
|
||||
return undefined; // more than one match
|
||||
}
|
||||
|
||||
result = match;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
192
packages/api-extractor/src/analyzer/AstSymbol.ts
Normal file
192
packages/api-extractor/src/analyzer/AstSymbol.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
import { InternalError } from '@rushstack/node-core-library';
|
||||
import type * as ts from 'typescript';
|
||||
import type { AstDeclaration } from './AstDeclaration.js';
|
||||
import { AstEntity } from './AstEntity.js';
|
||||
|
||||
/**
|
||||
* Constructor options for AstSymbol
|
||||
*/
|
||||
export interface IAstSymbolOptions {
|
||||
readonly followedSymbol: ts.Symbol;
|
||||
readonly isExternal: boolean;
|
||||
readonly localName: string;
|
||||
readonly nominalAnalysis: boolean;
|
||||
readonly parentAstSymbol: AstSymbol | undefined;
|
||||
readonly rootAstSymbol: AstSymbol | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The AstDeclaration and AstSymbol classes are API Extractor's equivalent of the compiler's
|
||||
* ts.Declaration and ts.Symbol objects. They are created by the `AstSymbolTable` class.
|
||||
*
|
||||
* @remarks
|
||||
* The AstSymbol represents the ts.Symbol information for an AstDeclaration. For example,
|
||||
* if a method has 3 overloads, each overloaded signature will have its own AstDeclaration,
|
||||
* but they will all share a common AstSymbol.
|
||||
*
|
||||
* For nested definitions, the AstSymbol has a unique parent (i.e. AstSymbol.rootAstSymbol),
|
||||
* but the parent/children for each AstDeclaration may be different. Consider this example:
|
||||
*
|
||||
* ```ts
|
||||
* export namespace N {
|
||||
* export function f(): void { }
|
||||
* }
|
||||
*
|
||||
* export interface N {
|
||||
* g(): void;
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Note how the parent/child relationships are different for the symbol tree versus
|
||||
* the declaration tree, and the declaration tree has two roots:
|
||||
*
|
||||
* ```
|
||||
* AstSymbol tree: AstDeclaration tree:
|
||||
* - N - N (namespace)
|
||||
* - f - f
|
||||
* - g - N (interface)
|
||||
* - g
|
||||
* ```
|
||||
*/
|
||||
export class AstSymbol extends AstEntity {
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public readonly localName: string; // abstract
|
||||
|
||||
/**
|
||||
* If true, then the `followedSymbol` (i.e. original declaration) of this symbol
|
||||
* is not part of the working package. The working package may still export this symbol,
|
||||
* but if so it should be emitted as an alias such as `export { X } from "package1";`.
|
||||
*/
|
||||
public readonly isExternal: boolean;
|
||||
|
||||
/**
|
||||
* The compiler symbol where this type was defined, after following any aliases.
|
||||
*
|
||||
* @remarks
|
||||
* This is a normal form that can be reached from any symbol alias by calling
|
||||
* `TypeScriptHelpers.followAliases()`. It can be compared to determine whether two
|
||||
* symbols refer to the same underlying type.
|
||||
*/
|
||||
public readonly followedSymbol: ts.Symbol;
|
||||
|
||||
/**
|
||||
* If true, then this AstSymbol represents a foreign object whose structure will be
|
||||
* ignored. The AstDeclaration objects will not have any parent or children, and its references
|
||||
* will not be analyzed.
|
||||
*
|
||||
* Nominal symbols are tracked e.g. when they are reexported by the working package.
|
||||
*/
|
||||
public readonly nominalAnalysis: boolean;
|
||||
|
||||
/**
|
||||
* Returns the symbol of the parent of this AstSymbol, or undefined if there is no parent.
|
||||
*
|
||||
* @remarks
|
||||
* If a symbol has multiple declarations, we assume (as an axiom) that their parent
|
||||
* declarations will belong to the same symbol. This means that the "parent" of a
|
||||
* symbol is a well-defined concept. However, the "children" of a symbol are not very
|
||||
* meaningful, because different declarations may have different nested members,
|
||||
* so we usually need to traverse declarations to find children.
|
||||
*/
|
||||
public readonly parentAstSymbol: AstSymbol | undefined;
|
||||
|
||||
/**
|
||||
* Returns the symbol of the root of the AstDeclaration hierarchy.
|
||||
*
|
||||
* @remarks
|
||||
* NOTE: If this AstSymbol is the root, then rootAstSymbol will point to itself.
|
||||
*/
|
||||
public readonly rootAstSymbol: AstSymbol;
|
||||
|
||||
/**
|
||||
* Additional information that is calculated later by the `Collector`. The actual type is `SymbolMetadata`,
|
||||
* but we declare it as `unknown` because consumers must obtain this object by calling
|
||||
* `Collector.fetchSymbolMetadata()`.
|
||||
*/
|
||||
public symbolMetadata: unknown;
|
||||
|
||||
private readonly _astDeclarations: AstDeclaration[];
|
||||
|
||||
// This flag is unused if this is not the root symbol.
|
||||
// Being "analyzed" is a property of the root symbol.
|
||||
private _analyzed: boolean = false;
|
||||
|
||||
public constructor(options: IAstSymbolOptions) {
|
||||
super();
|
||||
|
||||
this.followedSymbol = options.followedSymbol;
|
||||
this.localName = options.localName;
|
||||
this.isExternal = options.isExternal;
|
||||
this.nominalAnalysis = options.nominalAnalysis;
|
||||
this.parentAstSymbol = options.parentAstSymbol;
|
||||
this.rootAstSymbol = options.rootAstSymbol ?? this;
|
||||
this._astDeclarations = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The one or more declarations for this symbol.
|
||||
*
|
||||
* @remarks
|
||||
* For example, if this symbol is a method, then the declarations might be
|
||||
* various method overloads. If this symbol is a namespace, then the declarations
|
||||
* might be separate namespace blocks with the same name that get combined via
|
||||
* declaration merging.
|
||||
*/
|
||||
public get astDeclarations(): readonly AstDeclaration[] {
|
||||
return this._astDeclarations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the AstSymbolTable.analyze() was called for this object.
|
||||
* See that function for details.
|
||||
*
|
||||
* @remarks
|
||||
* AstSymbolTable.analyze() is always performed on the root AstSymbol. This function
|
||||
* returns true if-and-only-if the root symbol was analyzed.
|
||||
*/
|
||||
public get analyzed(): boolean {
|
||||
return this.rootAstSymbol._analyzed;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is an internal callback used when the AstSymbolTable attaches a new
|
||||
* AstDeclaration to this object.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public _notifyDeclarationAttach(astDeclaration: AstDeclaration): void {
|
||||
if (this.analyzed) {
|
||||
throw new InternalError('_notifyDeclarationAttach() called after analysis is already complete');
|
||||
}
|
||||
|
||||
this._astDeclarations.push(astDeclaration);
|
||||
}
|
||||
|
||||
/**
|
||||
* This is an internal callback used when the AstSymbolTable.analyze()
|
||||
* has processed this object.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public _notifyAnalyzed(): void {
|
||||
if (this.parentAstSymbol) {
|
||||
throw new InternalError('_notifyAnalyzed() called for an AstSymbol which is not the root');
|
||||
}
|
||||
|
||||
this._analyzed = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper that calls AstDeclaration.forEachDeclarationRecursive() for each AstDeclaration.
|
||||
*/
|
||||
public forEachDeclarationRecursive(action: (astDeclaration: AstDeclaration) => void): void {
|
||||
for (const astDeclaration of this.astDeclarations) {
|
||||
astDeclaration.forEachDeclarationRecursive(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
709
packages/api-extractor/src/analyzer/AstSymbolTable.ts
Normal file
709
packages/api-extractor/src/analyzer/AstSymbolTable.ts
Normal file
@@ -0,0 +1,709 @@
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
// for ts.SymbolFlags
|
||||
|
||||
import { type PackageJsonLookup, InternalError } from '@rushstack/node-core-library';
|
||||
import * as ts from 'typescript';
|
||||
import type { MessageRouter } from '../collector/MessageRouter';
|
||||
import { AstDeclaration } from './AstDeclaration.js';
|
||||
import type { AstEntity } from './AstEntity.js';
|
||||
import type { AstModule, AstModuleExportInfo } from './AstModule.js';
|
||||
import { AstNamespaceImport } from './AstNamespaceImport.js';
|
||||
import { AstSymbol } from './AstSymbol.js';
|
||||
import { ExportAnalyzer } from './ExportAnalyzer.js';
|
||||
import { PackageMetadataManager } from './PackageMetadataManager.js';
|
||||
import { SourceFileLocationFormatter } from './SourceFileLocationFormatter.js';
|
||||
import { SyntaxHelpers } from './SyntaxHelpers.js';
|
||||
import { TypeScriptHelpers } from './TypeScriptHelpers.js';
|
||||
import { TypeScriptInternals, type IGlobalVariableAnalyzer } from './TypeScriptInternals.js';
|
||||
|
||||
/**
|
||||
* Options for `AstSymbolTable._fetchAstSymbol()`
|
||||
*/
|
||||
export interface IFetchAstSymbolOptions {
|
||||
/**
|
||||
* True while populating the `AstSymbolTable`; false if we're doing a passive lookup
|
||||
* without adding anything new to the table
|
||||
*/
|
||||
addIfMissing: boolean;
|
||||
/**
|
||||
* The symbol after any symbol aliases have been followed using TypeScriptHelpers.followAliases()
|
||||
*/
|
||||
followedSymbol: ts.Symbol;
|
||||
|
||||
/**
|
||||
* If true, symbols with AstSymbol.nominalAnalysis=true will be returned.
|
||||
* Otherwise `undefined` will be returned for such symbols.
|
||||
*/
|
||||
includeNominalAnalysis: boolean;
|
||||
|
||||
/**
|
||||
* True if followedSymbol is not part of the working package
|
||||
*/
|
||||
isExternal: boolean;
|
||||
|
||||
/**
|
||||
* A hint to help `_fetchAstSymbol()` determine the `AstSymbol.localName`.
|
||||
*/
|
||||
localName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AstSymbolTable is the workhorse that builds AstSymbol and AstDeclaration objects.
|
||||
* It maintains a cache of already constructed objects. AstSymbolTable constructs
|
||||
* AstModule objects, but otherwise the state that it maintains is agnostic of
|
||||
* any particular entry point. (For example, it does not track whether a given AstSymbol
|
||||
* is "exported" or not.)
|
||||
*
|
||||
* Internally, AstSymbolTable relies on ExportAnalyzer to crawl import statements and determine where symbols
|
||||
* are declared (i.e. the AstImport information needed to import them).
|
||||
*/
|
||||
export class AstSymbolTable {
|
||||
private readonly _program: ts.Program;
|
||||
|
||||
private readonly _typeChecker: ts.TypeChecker;
|
||||
|
||||
private readonly _messageRouter: MessageRouter;
|
||||
|
||||
private readonly _globalVariableAnalyzer: IGlobalVariableAnalyzer;
|
||||
|
||||
private readonly _packageMetadataManager: PackageMetadataManager;
|
||||
|
||||
private readonly _exportAnalyzer: ExportAnalyzer;
|
||||
|
||||
private readonly _alreadyWarnedGlobalNames: Set<string>;
|
||||
|
||||
/**
|
||||
* A mapping from ts.Symbol --\> AstSymbol
|
||||
* NOTE: The AstSymbol.followedSymbol will always be a lookup key, but additional keys
|
||||
* are possible.
|
||||
*
|
||||
* After following type aliases, we use this map to look up the corresponding AstSymbol.
|
||||
*/
|
||||
private readonly _astSymbolsBySymbol: Map<ts.Symbol, AstSymbol> = new Map<ts.Symbol, AstSymbol>();
|
||||
|
||||
/**
|
||||
* A mapping from ts.Declaration --\> AstDeclaration
|
||||
*/
|
||||
private readonly _astDeclarationsByDeclaration: Map<ts.Node, AstDeclaration> = new Map<ts.Node, AstDeclaration>();
|
||||
|
||||
// Note that this is a mapping from specific AST nodes that we analyzed, based on the underlying symbol
|
||||
// for that node.
|
||||
private readonly _entitiesByNode: Map<ts.Identifier | ts.ImportTypeNode, AstEntity | undefined> = new Map<
|
||||
ts.Identifier,
|
||||
AstEntity | undefined
|
||||
>();
|
||||
|
||||
public constructor(
|
||||
program: ts.Program,
|
||||
typeChecker: ts.TypeChecker,
|
||||
packageJsonLookup: PackageJsonLookup,
|
||||
bundledPackageNames: ReadonlySet<string>,
|
||||
messageRouter: MessageRouter,
|
||||
) {
|
||||
this._program = program;
|
||||
this._typeChecker = typeChecker;
|
||||
this._messageRouter = messageRouter;
|
||||
this._globalVariableAnalyzer = TypeScriptInternals.getGlobalVariableAnalyzer(program);
|
||||
this._packageMetadataManager = new PackageMetadataManager(packageJsonLookup, messageRouter);
|
||||
|
||||
this._exportAnalyzer = new ExportAnalyzer(this._program, this._typeChecker, bundledPackageNames, {
|
||||
analyze: this.analyze.bind(this),
|
||||
fetchAstSymbol: this._fetchAstSymbol.bind(this),
|
||||
});
|
||||
|
||||
this._alreadyWarnedGlobalNames = new Set<string>();
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to analyze an entry point that belongs to the working package.
|
||||
*/
|
||||
public fetchAstModuleFromWorkingPackage(sourceFile: ts.SourceFile): AstModule {
|
||||
return this._exportAnalyzer.fetchAstModuleFromSourceFile(sourceFile, undefined, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* This crawls the specified entry point and collects the full set of exported AstSymbols.
|
||||
*/
|
||||
public fetchAstModuleExportInfo(astModule: AstModule): AstModuleExportInfo {
|
||||
return this._exportAnalyzer.fetchAstModuleExportInfo(astModule);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to retrieve an export by name from the specified `AstModule`.
|
||||
* Returns undefined if no match was found.
|
||||
*/
|
||||
public tryGetExportOfAstModule(exportName: string, astModule: AstModule): AstEntity | undefined {
|
||||
return this._exportAnalyzer.tryGetExportOfAstModule(exportName, astModule);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that AstSymbol.analyzed is true for the provided symbol. The operation
|
||||
* starts from the root symbol and then fills out all children of all declarations, and
|
||||
* also calculates AstDeclaration.referencedAstSymbols for all declarations.
|
||||
* If the symbol is not imported, any non-imported references are also analyzed.
|
||||
*
|
||||
* @remarks
|
||||
* This is an expensive operation, so we only perform it for top-level exports of an
|
||||
* the AstModule. For example, if some code references a nested class inside
|
||||
* a namespace from another library, we do not analyze any of that class's siblings
|
||||
* or members. (We do always construct its parents however, since AstDefinition.parent
|
||||
* is immutable, and needed e.g. to calculate release tag inheritance.)
|
||||
*/
|
||||
public analyze(astEntity: AstEntity): void {
|
||||
if (astEntity instanceof AstSymbol) {
|
||||
this._analyzeAstSymbol(astEntity);
|
||||
return;
|
||||
}
|
||||
|
||||
if (astEntity instanceof AstNamespaceImport) {
|
||||
this._analyzeAstNamespaceImport(astEntity);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For a given astDeclaration, this efficiently finds the child corresponding to the
|
||||
* specified ts.Node. It is assumed that AstDeclaration.isSupportedSyntaxKind() would return true for
|
||||
* that node type, and that the node is an immediate child of the provided AstDeclaration.
|
||||
*/
|
||||
// NOTE: This could be a method of AstSymbol if it had a backpointer to its AstSymbolTable.
|
||||
public getChildAstDeclarationByNode(node: ts.Node, parentAstDeclaration: AstDeclaration): AstDeclaration {
|
||||
if (!parentAstDeclaration.astSymbol.analyzed) {
|
||||
throw new Error('getChildDeclarationByNode() cannot be used for an AstSymbol that was not analyzed');
|
||||
}
|
||||
|
||||
const childAstDeclaration: AstDeclaration | undefined = this._astDeclarationsByDeclaration.get(node);
|
||||
if (!childAstDeclaration) {
|
||||
throw new Error('Child declaration not found for the specified node');
|
||||
}
|
||||
|
||||
if (childAstDeclaration.parent !== parentAstDeclaration) {
|
||||
throw new InternalError('The found child is not attached to the parent AstDeclaration');
|
||||
}
|
||||
|
||||
return childAstDeclaration;
|
||||
}
|
||||
|
||||
/**
|
||||
* For a given ts.Identifier that is part of an AstSymbol that we analyzed, return the AstEntity that
|
||||
* it refers to. Returns undefined if it doesn't refer to anything interesting.
|
||||
*
|
||||
* @remarks
|
||||
* Throws an Error if the ts.Identifier is not part of node tree that was analyzed.
|
||||
*/
|
||||
public tryGetEntityForNode(identifier: ts.Identifier | ts.ImportTypeNode): AstEntity | undefined {
|
||||
if (!this._entitiesByNode.has(identifier)) {
|
||||
throw new InternalError('tryGetEntityForIdentifier() called for an identifier that was not analyzed');
|
||||
}
|
||||
|
||||
return this._entitiesByNode.get(identifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an AstSymbol.localName for a given ts.Symbol. In the current implementation, the localName is
|
||||
* a TypeScript-like expression that may be a string literal or ECMAScript symbol expression.
|
||||
*
|
||||
* ```ts
|
||||
* class X {
|
||||
* // localName="identifier"
|
||||
* public identifier: number = 1;
|
||||
* // localName="\"identifier\""
|
||||
* public "quoted string!": number = 2;
|
||||
* // localName="[MyNamespace.MySymbol]"
|
||||
* public [MyNamespace.MySymbol]: number = 3;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
public static getLocalNameForSymbol(symbol: ts.Symbol): string {
|
||||
// TypeScript binds well-known ECMAScript symbols like "[Symbol.iterator]" as "__@iterator".
|
||||
// Decode it back into "[Symbol.iterator]".
|
||||
const wellKnownSymbolName: string | undefined = TypeScriptHelpers.tryDecodeWellKnownSymbolName(symbol.escapedName);
|
||||
if (wellKnownSymbolName) {
|
||||
return wellKnownSymbolName;
|
||||
}
|
||||
|
||||
const isUniqueSymbol: boolean = TypeScriptHelpers.isUniqueSymbolName(symbol.escapedName);
|
||||
|
||||
// We will try to obtain the name from a declaration; otherwise we'll fall back to the symbol name.
|
||||
let unquotedName: string = symbol.name;
|
||||
|
||||
for (const declaration of symbol.declarations ?? []) {
|
||||
// Handle cases such as "export default class X { }" where the symbol name is "default"
|
||||
// but the local name is "X".
|
||||
const localSymbol: ts.Symbol | undefined = TypeScriptInternals.tryGetLocalSymbol(declaration);
|
||||
if (localSymbol) {
|
||||
unquotedName = localSymbol.name;
|
||||
}
|
||||
|
||||
// If it is a non-well-known symbol, then return the late-bound name. For example, "X.Y.z" in this example:
|
||||
//
|
||||
// namespace X {
|
||||
// export namespace Y {
|
||||
// export const z: unique symbol = Symbol("z");
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// class C {
|
||||
// public [X.Y.z](): void { }
|
||||
// }
|
||||
//
|
||||
if (isUniqueSymbol) {
|
||||
const declarationName: ts.DeclarationName | undefined = ts.getNameOfDeclaration(declaration);
|
||||
if (declarationName && ts.isComputedPropertyName(declarationName)) {
|
||||
const lateBoundName: string | undefined = TypeScriptHelpers.tryGetLateBoundName(declarationName);
|
||||
if (lateBoundName) {
|
||||
// Here the string may contain an expression such as "[X.Y.z]". Names starting with "[" are always
|
||||
// expressions. If a string literal contains those characters, the code below will JSON.stringify() it
|
||||
// to avoid a collision.
|
||||
return lateBoundName;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise that name may come from a quoted string or pseudonym like `__constructor`.
|
||||
// If the string is not a safe identifier, then we must add quotes.
|
||||
// Note that if it was quoted but did not need to be quoted, here we will remove the quotes.
|
||||
if (!SyntaxHelpers.isSafeUnquotedMemberIdentifier(unquotedName)) {
|
||||
// For API Extractor's purposes, a canonical form is more appropriate than trying to reflect whatever
|
||||
// appeared in the source code. The code is not even guaranteed to be consistent, for example:
|
||||
//
|
||||
// class X {
|
||||
// public "f1"(x: string): void;
|
||||
// public f1(x: boolean): void;
|
||||
// public 'f1'(x: string | boolean): void { }
|
||||
// }
|
||||
return JSON.stringify(unquotedName);
|
||||
}
|
||||
|
||||
return unquotedName;
|
||||
}
|
||||
|
||||
private _analyzeAstNamespaceImport(astNamespaceImport: AstNamespaceImport): void {
|
||||
if (astNamespaceImport.analyzed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// mark before actual analyzing, to handle module cyclic reexport
|
||||
astNamespaceImport.analyzed = true;
|
||||
|
||||
const exportedLocalEntities: Map<string, AstEntity> = this.fetchAstModuleExportInfo(
|
||||
astNamespaceImport.astModule,
|
||||
).exportedLocalEntities;
|
||||
|
||||
for (const exportedEntity of exportedLocalEntities.values()) {
|
||||
this.analyze(exportedEntity);
|
||||
}
|
||||
}
|
||||
|
||||
private _analyzeAstSymbol(astSymbol: AstSymbol): void {
|
||||
if (astSymbol.analyzed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (astSymbol.nominalAnalysis) {
|
||||
// We don't analyze nominal symbols
|
||||
astSymbol._notifyAnalyzed();
|
||||
return;
|
||||
}
|
||||
|
||||
// Start at the root of the tree
|
||||
const rootAstSymbol: AstSymbol = astSymbol.rootAstSymbol;
|
||||
|
||||
// Calculate the full child tree for each definition
|
||||
for (const astDeclaration of rootAstSymbol.astDeclarations) {
|
||||
this._analyzeChildTree(astDeclaration.declaration, astDeclaration);
|
||||
}
|
||||
|
||||
rootAstSymbol._notifyAnalyzed();
|
||||
|
||||
if (!astSymbol.isExternal) {
|
||||
// If this symbol is non-external (i.e. it belongs to the working package), then we also analyze any
|
||||
// referencedAstSymbols that are non-external. For example, this ensures that forgotten exports
|
||||
// get analyzed.
|
||||
rootAstSymbol.forEachDeclarationRecursive((astDeclaration: AstDeclaration) => {
|
||||
for (const referencedAstEntity of astDeclaration.referencedAstEntities) {
|
||||
// Walk up to the root of the tree, looking for any imports along the way
|
||||
if (referencedAstEntity instanceof AstSymbol && !referencedAstEntity.isExternal) {
|
||||
this._analyzeAstSymbol(referencedAstEntity);
|
||||
}
|
||||
|
||||
if (referencedAstEntity instanceof AstNamespaceImport && !referencedAstEntity.astModule.isExternal) {
|
||||
this._analyzeAstNamespaceImport(referencedAstEntity);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Used by analyze to recursively analyze the entire child tree.
|
||||
*/
|
||||
private _analyzeChildTree(node: ts.Node, governingAstDeclaration: AstDeclaration): void {
|
||||
switch (node.kind) {
|
||||
case ts.SyntaxKind.JSDocComment: // Skip JSDoc comments - TS considers @param tags TypeReference nodes
|
||||
return;
|
||||
|
||||
// Is this a reference to another AstSymbol?
|
||||
case ts.SyntaxKind.TypeReference: // general type references
|
||||
case ts.SyntaxKind.ExpressionWithTypeArguments: // special case for e.g. the "extends" keyword
|
||||
case ts.SyntaxKind.ComputedPropertyName: // used for EcmaScript "symbols", e.g. "[toPrimitive]".
|
||||
case ts.SyntaxKind.TypeQuery: // represents for "typeof X" as a type
|
||||
{
|
||||
// Sometimes the type reference will involve multiple identifiers, e.g. "a.b.C".
|
||||
// In this case, we only need to worry about importing the first identifier,
|
||||
// so do a depth-first search for it:
|
||||
const identifierNode: ts.Identifier | undefined = TypeScriptHelpers.findFirstChildNode(
|
||||
node,
|
||||
ts.SyntaxKind.Identifier,
|
||||
);
|
||||
|
||||
if (identifierNode) {
|
||||
let referencedAstEntity: AstEntity | undefined = this._entitiesByNode.get(identifierNode);
|
||||
if (!referencedAstEntity) {
|
||||
const symbol: ts.Symbol | undefined = this._typeChecker.getSymbolAtLocation(identifierNode);
|
||||
if (!symbol) {
|
||||
throw new Error('Symbol not found for identifier: ' + identifierNode.getText());
|
||||
}
|
||||
|
||||
// Normally we expect getSymbolAtLocation() to take us to a declaration within the same source
|
||||
// file, or else to an explicit "import" statement within the same source file. But in certain
|
||||
// situations (e.g. a global variable) the symbol will refer to a declaration in some other
|
||||
// source file. We'll call that case a "displaced symbol".
|
||||
//
|
||||
// For more info, see this discussion:
|
||||
// https://github.com/microsoft/rushstack/issues/1765#issuecomment-595559849
|
||||
let displacedSymbol = true;
|
||||
for (const declaration of symbol.declarations ?? []) {
|
||||
if (declaration.getSourceFile() === identifierNode.getSourceFile()) {
|
||||
displacedSymbol = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (displacedSymbol) {
|
||||
if (this._globalVariableAnalyzer.hasGlobalName(identifierNode.text)) {
|
||||
// If the displaced symbol is a global variable, then API Extractor simply ignores it.
|
||||
// Ambient declarations typically describe the runtime environment (provided by an API consumer),
|
||||
// so we don't bother analyzing them as an API contract. (There are probably some packages
|
||||
// that include interesting global variables in their API, but API Extractor doesn't support
|
||||
// that yet; it would be a feature request.)
|
||||
|
||||
if (this._messageRouter.showDiagnostics && !this._alreadyWarnedGlobalNames.has(identifierNode.text)) {
|
||||
this._alreadyWarnedGlobalNames.add(identifierNode.text);
|
||||
this._messageRouter.logDiagnostic(
|
||||
`Ignoring reference to global variable "${identifierNode.text}"` +
|
||||
` in ` +
|
||||
SourceFileLocationFormatter.formatDeclaration(identifierNode),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// If you encounter this, please report a bug with a repro. We're interested to know
|
||||
// how it can occur.
|
||||
throw new InternalError(`Unable to follow symbol for "${identifierNode.text}"`);
|
||||
}
|
||||
} else {
|
||||
referencedAstEntity = this._exportAnalyzer.fetchReferencedAstEntity(
|
||||
symbol,
|
||||
governingAstDeclaration.astSymbol.isExternal,
|
||||
);
|
||||
|
||||
this._entitiesByNode.set(identifierNode, referencedAstEntity);
|
||||
}
|
||||
}
|
||||
|
||||
if (referencedAstEntity) {
|
||||
governingAstDeclaration._notifyReferencedAstEntity(referencedAstEntity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
// Is this the identifier for the governingAstDeclaration?
|
||||
case ts.SyntaxKind.Identifier:
|
||||
{
|
||||
const identifierNode: ts.Identifier = node as ts.Identifier;
|
||||
if (!this._entitiesByNode.has(identifierNode)) {
|
||||
const symbol: ts.Symbol | undefined = this._typeChecker.getSymbolAtLocation(identifierNode);
|
||||
|
||||
let referencedAstEntity: AstEntity | undefined;
|
||||
|
||||
if (symbol === governingAstDeclaration.astSymbol.followedSymbol) {
|
||||
referencedAstEntity = this._fetchEntityForNode(identifierNode, governingAstDeclaration);
|
||||
}
|
||||
|
||||
this._entitiesByNode.set(identifierNode, referencedAstEntity);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case ts.SyntaxKind.ImportType:
|
||||
{
|
||||
const importTypeNode: ts.ImportTypeNode = node as ts.ImportTypeNode;
|
||||
let referencedAstEntity: AstEntity | undefined = this._entitiesByNode.get(importTypeNode);
|
||||
|
||||
if (!this._entitiesByNode.has(importTypeNode)) {
|
||||
referencedAstEntity = this._fetchEntityForNode(importTypeNode, governingAstDeclaration);
|
||||
|
||||
if (!referencedAstEntity) {
|
||||
// This should never happen
|
||||
throw new Error('Failed to fetch entity for import() type node: ' + importTypeNode.getText());
|
||||
}
|
||||
|
||||
this._entitiesByNode.set(importTypeNode, referencedAstEntity);
|
||||
}
|
||||
|
||||
if (referencedAstEntity) {
|
||||
governingAstDeclaration._notifyReferencedAstEntity(referencedAstEntity);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// Is this node declaring a new AstSymbol?
|
||||
const newGoverningAstDeclaration: AstDeclaration | undefined = this._fetchAstDeclaration(
|
||||
node,
|
||||
governingAstDeclaration.astSymbol.isExternal,
|
||||
);
|
||||
|
||||
for (const childNode of node.getChildren()) {
|
||||
this._analyzeChildTree(childNode, newGoverningAstDeclaration ?? governingAstDeclaration);
|
||||
}
|
||||
}
|
||||
|
||||
private _fetchEntityForNode(
|
||||
node: ts.Identifier | ts.ImportTypeNode,
|
||||
governingAstDeclaration: AstDeclaration,
|
||||
): AstEntity | undefined {
|
||||
let referencedAstEntity: AstEntity | undefined = this._entitiesByNode.get(node);
|
||||
if (!referencedAstEntity) {
|
||||
if (node.kind === ts.SyntaxKind.ImportType) {
|
||||
referencedAstEntity = this._exportAnalyzer.fetchReferencedAstEntityFromImportTypeNode(
|
||||
node,
|
||||
governingAstDeclaration.astSymbol.isExternal,
|
||||
);
|
||||
} else {
|
||||
const symbol: ts.Symbol | undefined = this._typeChecker.getSymbolAtLocation(node);
|
||||
if (!symbol) {
|
||||
throw new Error('Symbol not found for identifier: ' + node.getText());
|
||||
}
|
||||
|
||||
referencedAstEntity = this._exportAnalyzer.fetchReferencedAstEntity(
|
||||
symbol,
|
||||
governingAstDeclaration.astSymbol.isExternal,
|
||||
);
|
||||
}
|
||||
|
||||
this._entitiesByNode.set(node, referencedAstEntity);
|
||||
}
|
||||
|
||||
return referencedAstEntity;
|
||||
}
|
||||
|
||||
private _fetchAstDeclaration(node: ts.Node, isExternal: boolean): AstDeclaration | undefined {
|
||||
if (!AstDeclaration.isSupportedSyntaxKind(node.kind)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const symbol: ts.Symbol | undefined = TypeScriptHelpers.getSymbolForDeclaration(
|
||||
node as ts.Declaration,
|
||||
this._typeChecker,
|
||||
);
|
||||
if (!symbol) {
|
||||
throw new InternalError('Unable to find symbol for node');
|
||||
}
|
||||
|
||||
const astSymbol: AstSymbol | undefined = this._fetchAstSymbol({
|
||||
followedSymbol: symbol,
|
||||
isExternal,
|
||||
includeNominalAnalysis: true,
|
||||
addIfMissing: true,
|
||||
});
|
||||
|
||||
if (!astSymbol) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const astDeclaration: AstDeclaration | undefined = this._astDeclarationsByDeclaration.get(node);
|
||||
|
||||
if (!astDeclaration) {
|
||||
throw new InternalError('Unable to find constructed AstDeclaration');
|
||||
}
|
||||
|
||||
return astDeclaration;
|
||||
}
|
||||
|
||||
private _fetchAstSymbol(options: IFetchAstSymbolOptions): AstSymbol | undefined {
|
||||
const followedSymbol: ts.Symbol = options.followedSymbol;
|
||||
|
||||
// Filter out symbols representing constructs that we don't care about
|
||||
const arbitraryDeclaration: ts.Declaration | undefined = TypeScriptHelpers.tryGetADeclaration(followedSymbol);
|
||||
if (!arbitraryDeclaration) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
followedSymbol.flags & (ts.SymbolFlags.TypeParameter | ts.SymbolFlags.TypeLiteral | ts.SymbolFlags.Transient) &&
|
||||
!TypeScriptInternals.isLateBoundSymbol(followedSymbol)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// API Extractor doesn't analyze ambient declarations at all
|
||||
if (
|
||||
TypeScriptHelpers.isAmbient(followedSymbol, this._typeChecker) && // We make a special exemption for ambient declarations that appear in a source file containing
|
||||
// an "export=" declaration that allows them to be imported as non-ambient.
|
||||
!this._exportAnalyzer.isImportableAmbientSourceFile(arbitraryDeclaration.getSourceFile())
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Make sure followedSymbol isn't an alias for something else
|
||||
if (TypeScriptHelpers.isFollowableAlias(followedSymbol, this._typeChecker)) {
|
||||
// We expect the caller to have already followed any aliases
|
||||
throw new InternalError('AstSymbolTable._fetchAstSymbol() cannot be called with a symbol alias');
|
||||
}
|
||||
|
||||
let astSymbol: AstSymbol | undefined = this._astSymbolsBySymbol.get(followedSymbol);
|
||||
|
||||
if (!astSymbol) {
|
||||
// None of the above lookups worked, so create a new entry...
|
||||
let nominalAnalysis = false;
|
||||
|
||||
if (options.isExternal) {
|
||||
// If the file is from an external package that does not support AEDoc, normally we ignore it completely.
|
||||
// But in some cases (e.g. checking star exports of an external package) we need an AstSymbol to
|
||||
// represent it, but we don't need to analyze its sibling/children.
|
||||
const followedSymbolSourceFileName: string = arbitraryDeclaration.getSourceFile().fileName;
|
||||
|
||||
if (!this._packageMetadataManager.isAedocSupportedFor(followedSymbolSourceFileName)) {
|
||||
nominalAnalysis = true;
|
||||
|
||||
if (!options.includeNominalAnalysis) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let parentAstSymbol: AstSymbol | undefined;
|
||||
|
||||
if (!nominalAnalysis) {
|
||||
for (const declaration of followedSymbol.declarations ?? []) {
|
||||
if (!AstDeclaration.isSupportedSyntaxKind(declaration.kind)) {
|
||||
throw new InternalError(
|
||||
`The "${followedSymbol.name}" symbol has a` +
|
||||
` ts.SyntaxKind.${ts.SyntaxKind[declaration.kind]} declaration which is not (yet?)` +
|
||||
` supported by API Extractor`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// We always fetch the entire chain of parents for each declaration.
|
||||
// (Children/siblings are only analyzed on demand.)
|
||||
|
||||
// Key assumptions behind this squirrely logic:
|
||||
//
|
||||
// IF a given symbol has two declarations D1 and D2; AND
|
||||
// If D1 has a parent P1, then
|
||||
// - D2 will also have a parent P2; AND
|
||||
// - P1 and P2's symbol will be the same
|
||||
// - but P1 and P2 may be different (e.g. merged namespaces containing merged interfaces)
|
||||
|
||||
// Is there a parent AstSymbol? First we check to see if there is a parent declaration:
|
||||
if (arbitraryDeclaration) {
|
||||
const arbitraryParentDeclaration: ts.Node | undefined =
|
||||
this._tryFindFirstAstDeclarationParent(arbitraryDeclaration);
|
||||
|
||||
if (arbitraryParentDeclaration) {
|
||||
const parentSymbol: ts.Symbol = TypeScriptHelpers.getSymbolForDeclaration(
|
||||
arbitraryParentDeclaration as ts.Declaration,
|
||||
this._typeChecker,
|
||||
);
|
||||
|
||||
parentAstSymbol = this._fetchAstSymbol({
|
||||
followedSymbol: parentSymbol,
|
||||
isExternal: options.isExternal,
|
||||
includeNominalAnalysis: false,
|
||||
addIfMissing: true,
|
||||
});
|
||||
if (!parentAstSymbol) {
|
||||
throw new InternalError('Unable to construct a parent AstSymbol for ' + followedSymbol.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const localName: string | undefined = options.localName ?? AstSymbolTable.getLocalNameForSymbol(followedSymbol);
|
||||
|
||||
astSymbol = new AstSymbol({
|
||||
followedSymbol,
|
||||
localName,
|
||||
isExternal: options.isExternal,
|
||||
nominalAnalysis,
|
||||
parentAstSymbol,
|
||||
rootAstSymbol: parentAstSymbol ? parentAstSymbol.rootAstSymbol : undefined,
|
||||
});
|
||||
|
||||
this._astSymbolsBySymbol.set(followedSymbol, astSymbol);
|
||||
|
||||
// Okay, now while creating the declarations we will wire them up to the
|
||||
// their corresponding parent declarations
|
||||
for (const declaration of followedSymbol.declarations ?? []) {
|
||||
let parentAstDeclaration: AstDeclaration | undefined;
|
||||
if (parentAstSymbol) {
|
||||
const parentDeclaration: ts.Node | undefined = this._tryFindFirstAstDeclarationParent(declaration);
|
||||
|
||||
if (!parentDeclaration) {
|
||||
throw new InternalError('Missing parent declaration');
|
||||
}
|
||||
|
||||
parentAstDeclaration = this._astDeclarationsByDeclaration.get(parentDeclaration);
|
||||
if (!parentAstDeclaration) {
|
||||
throw new InternalError('Missing parent AstDeclaration');
|
||||
}
|
||||
}
|
||||
|
||||
const astDeclaration: AstDeclaration = new AstDeclaration({
|
||||
declaration,
|
||||
astSymbol,
|
||||
parent: parentAstDeclaration,
|
||||
});
|
||||
|
||||
this._astDeclarationsByDeclaration.set(declaration, astDeclaration);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.isExternal !== astSymbol.isExternal) {
|
||||
throw new InternalError(
|
||||
`Cannot assign isExternal=${options.isExternal} for` +
|
||||
` the symbol ${astSymbol.localName} because it was previously registered` +
|
||||
` with isExternal=${astSymbol.isExternal}`,
|
||||
);
|
||||
}
|
||||
|
||||
return astSymbol;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the first parent satisfying isAstDeclaration(), or undefined if none is found.
|
||||
*/
|
||||
private _tryFindFirstAstDeclarationParent(node: ts.Node): ts.Node | undefined {
|
||||
let currentNode: ts.Node | undefined = node.parent;
|
||||
while (currentNode) {
|
||||
if (AstDeclaration.isSupportedSyntaxKind(currentNode.kind)) {
|
||||
return currentNode;
|
||||
}
|
||||
|
||||
currentNode = currentNode.parent;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
946
packages/api-extractor/src/analyzer/ExportAnalyzer.ts
Normal file
946
packages/api-extractor/src/analyzer/ExportAnalyzer.ts
Normal file
@@ -0,0 +1,946 @@
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
import { InternalError } from '@rushstack/node-core-library';
|
||||
import * as ts from 'typescript';
|
||||
import type { AstEntity } from './AstEntity.js';
|
||||
import { AstImport, type IAstImportOptions, AstImportKind } from './AstImport.js';
|
||||
import { AstModule, AstModuleExportInfo } from './AstModule.js';
|
||||
import { AstNamespaceImport } from './AstNamespaceImport.js';
|
||||
import { AstSymbol } from './AstSymbol.js';
|
||||
import type { IFetchAstSymbolOptions } from './AstSymbolTable.js';
|
||||
import { SourceFileLocationFormatter } from './SourceFileLocationFormatter.js';
|
||||
import { SyntaxHelpers } from './SyntaxHelpers.js';
|
||||
import { TypeScriptHelpers } from './TypeScriptHelpers.js';
|
||||
import { TypeScriptInternals } from './TypeScriptInternals.js';
|
||||
|
||||
/**
|
||||
* Exposes the minimal APIs from AstSymbolTable that are needed by ExportAnalyzer.
|
||||
*
|
||||
* In particular, we want ExportAnalyzer to be able to call AstSymbolTable._fetchAstSymbol() even though it
|
||||
* is a very private API that should not be exposed to any other components.
|
||||
*/
|
||||
export interface IAstSymbolTable {
|
||||
analyze(astEntity: AstEntity): void;
|
||||
|
||||
fetchAstSymbol(options: IFetchAstSymbolOptions): AstSymbol | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used with ExportAnalyzer.fetchAstModuleBySourceFile() to provide contextual information about how the source file
|
||||
* was imported.
|
||||
*/
|
||||
interface IAstModuleReference {
|
||||
/**
|
||||
* For example, if we are following a statement like `import { X } from 'some-package'`, this will be the
|
||||
* string `"some-package"`.
|
||||
*/
|
||||
moduleSpecifier: string;
|
||||
|
||||
/**
|
||||
* For example, if we are following a statement like `import { X } from 'some-package'`, this will be the
|
||||
* symbol for `X`.
|
||||
*/
|
||||
moduleSpecifierSymbol: ts.Symbol;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ExportAnalyzer is an internal part of AstSymbolTable that has been moved out into its own source file
|
||||
* because it is a complex and mostly self-contained algorithm.
|
||||
*
|
||||
* Its job is to build up AstModule objects by crawling import statements to discover where declarations come from.
|
||||
* This is conceptually the same as the compiler's own TypeChecker.getExportsOfModule(), except that when
|
||||
* ExportAnalyzer encounters a declaration that was imported from an external package, it remembers how it was imported
|
||||
* (i.e. the AstImport object). Today the compiler API does not expose this information, which is crucial for
|
||||
* generating .d.ts rollups.
|
||||
*/
|
||||
export class ExportAnalyzer {
|
||||
private readonly _program: ts.Program;
|
||||
|
||||
private readonly _typeChecker: ts.TypeChecker;
|
||||
|
||||
private readonly _bundledPackageNames: ReadonlySet<string>;
|
||||
|
||||
private readonly _astSymbolTable: IAstSymbolTable;
|
||||
|
||||
private readonly _astModulesByModuleSymbol: Map<ts.Symbol, AstModule> = new Map<ts.Symbol, AstModule>();
|
||||
|
||||
// Used with isImportableAmbientSourceFile()
|
||||
private readonly _importableAmbientSourceFiles: Set<ts.SourceFile> = new Set<ts.SourceFile>();
|
||||
|
||||
private readonly _astImportsByKey: Map<string, AstImport> = new Map<string, AstImport>();
|
||||
|
||||
private readonly _astNamespaceImportByModule: Map<AstModule, AstNamespaceImport> = new Map();
|
||||
|
||||
public constructor(
|
||||
program: ts.Program,
|
||||
typeChecker: ts.TypeChecker,
|
||||
bundledPackageNames: ReadonlySet<string>,
|
||||
astSymbolTable: IAstSymbolTable,
|
||||
) {
|
||||
this._program = program;
|
||||
this._typeChecker = typeChecker;
|
||||
this._bundledPackageNames = bundledPackageNames;
|
||||
this._astSymbolTable = astSymbolTable;
|
||||
}
|
||||
|
||||
/**
|
||||
* For a given source file, this analyzes all of its exports and produces an AstModule object.
|
||||
*
|
||||
* @param sourceFile - the sourceFile
|
||||
* @param moduleReference - contextual information about the import statement that took us to this source file.
|
||||
* or `undefined` if this source file is the initial entry point
|
||||
* @param isExternal - whether the given `moduleReference` is external.
|
||||
*/
|
||||
public fetchAstModuleFromSourceFile(
|
||||
sourceFile: ts.SourceFile,
|
||||
moduleReference: IAstModuleReference | undefined,
|
||||
isExternal: boolean,
|
||||
): AstModule {
|
||||
const moduleSymbol: ts.Symbol = this._getModuleSymbolFromSourceFile(sourceFile, moduleReference);
|
||||
|
||||
// Don't traverse into a module that we already processed before:
|
||||
// The compiler allows m1 to have "export * from 'm2'" and "export * from 'm3'",
|
||||
// even if m2 and m3 both have "export * from 'm4'".
|
||||
let astModule: AstModule | undefined = this._astModulesByModuleSymbol.get(moduleSymbol);
|
||||
if (!astModule) {
|
||||
// (If moduleReference === undefined, then this is the entry point of the local project being analyzed.)
|
||||
const externalModulePath: string | undefined =
|
||||
moduleReference !== undefined && isExternal ? moduleReference.moduleSpecifier : undefined;
|
||||
|
||||
astModule = new AstModule({ sourceFile, moduleSymbol, externalModulePath });
|
||||
|
||||
this._astModulesByModuleSymbol.set(moduleSymbol, astModule);
|
||||
|
||||
if (astModule.isExternal) {
|
||||
// It's an external package, so do the special simplified analysis that doesn't crawl into referenced modules
|
||||
for (const exportedSymbol of this._typeChecker.getExportsOfModule(moduleSymbol)) {
|
||||
if (externalModulePath === undefined) {
|
||||
throw new InternalError('Failed assertion: externalModulePath=undefined but astModule.isExternal=true');
|
||||
}
|
||||
|
||||
const followedSymbol: ts.Symbol = TypeScriptHelpers.followAliases(exportedSymbol, this._typeChecker);
|
||||
|
||||
// Ignore virtual symbols that don't have any declarations
|
||||
const arbitraryDeclaration: ts.Declaration | undefined = TypeScriptHelpers.tryGetADeclaration(followedSymbol);
|
||||
if (arbitraryDeclaration) {
|
||||
const astSymbol: AstSymbol | undefined = this._astSymbolTable.fetchAstSymbol({
|
||||
followedSymbol,
|
||||
isExternal: astModule.isExternal,
|
||||
includeNominalAnalysis: true,
|
||||
addIfMissing: true,
|
||||
});
|
||||
|
||||
if (!astSymbol) {
|
||||
throw new Error(
|
||||
`Unsupported export ${JSON.stringify(exportedSymbol.name)}:\n` +
|
||||
SourceFileLocationFormatter.formatDeclaration(arbitraryDeclaration),
|
||||
);
|
||||
}
|
||||
|
||||
astModule.cachedExportedEntities.set(exportedSymbol.name, astSymbol);
|
||||
}
|
||||
}
|
||||
} else if (moduleSymbol.exports) {
|
||||
// The module is part of the local project, so do the full analysis
|
||||
// The "export * from 'module-name';" declarations are all attached to a single virtual symbol
|
||||
// whose name is InternalSymbolName.ExportStar
|
||||
const exportStarSymbol: ts.Symbol | undefined = moduleSymbol.exports.get(ts.InternalSymbolName.ExportStar);
|
||||
if (exportStarSymbol) {
|
||||
for (const exportStarDeclaration of exportStarSymbol.getDeclarations() ?? []) {
|
||||
if (ts.isExportDeclaration(exportStarDeclaration)) {
|
||||
const starExportedModule: AstModule | undefined = this._fetchSpecifierAstModule(
|
||||
exportStarDeclaration,
|
||||
exportStarSymbol,
|
||||
);
|
||||
|
||||
if (starExportedModule !== undefined) {
|
||||
astModule.starExportedModules.add(starExportedModule);
|
||||
}
|
||||
} else {
|
||||
// Ignore ExportDeclaration nodes that don't match the expected pattern
|
||||
// Should we report a warning?
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return astModule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the symbol for the module corresponding to the ts.SourceFile that is being imported/exported.
|
||||
*
|
||||
* @remarks
|
||||
* The `module` keyword can be used to declare multiple TypeScript modules inside a single source file.
|
||||
* (This is a deprecated construct and mainly used for typings such as `@types/node`.) In this situation,
|
||||
* `moduleReference` helps us to fish out the correct module symbol.
|
||||
*/
|
||||
private _getModuleSymbolFromSourceFile(
|
||||
sourceFile: ts.SourceFile,
|
||||
moduleReference: IAstModuleReference | undefined,
|
||||
): ts.Symbol {
|
||||
const moduleSymbol: ts.Symbol | undefined = TypeScriptInternals.tryGetSymbolForDeclaration(
|
||||
sourceFile,
|
||||
this._typeChecker,
|
||||
);
|
||||
if (moduleSymbol !== undefined) {
|
||||
// This is the normal case. The SourceFile acts is a module and has a symbol.
|
||||
return moduleSymbol;
|
||||
}
|
||||
|
||||
if (
|
||||
moduleReference !== undefined && // But there is also an elaborate case where the source file contains one or more "module" declarations,
|
||||
// and our moduleReference took us to one of those.
|
||||
|
||||
(moduleReference.moduleSpecifierSymbol.flags & ts.SymbolFlags.Alias) !== 0
|
||||
) {
|
||||
// Follow the import/export declaration to one hop the exported item inside the target module
|
||||
let followedSymbol: ts.Symbol | undefined = TypeScriptInternals.getImmediateAliasedSymbol(
|
||||
moduleReference.moduleSpecifierSymbol,
|
||||
this._typeChecker,
|
||||
);
|
||||
|
||||
if (followedSymbol === undefined) {
|
||||
// This is a workaround for a compiler bug where getImmediateAliasedSymbol() sometimes returns undefined
|
||||
followedSymbol = this._typeChecker.getAliasedSymbol(moduleReference.moduleSpecifierSymbol);
|
||||
}
|
||||
|
||||
if (followedSymbol !== undefined && followedSymbol !== moduleReference.moduleSpecifierSymbol) {
|
||||
// The parent of the exported symbol will be the module that we're importing from
|
||||
const parent: ts.Symbol | undefined = TypeScriptInternals.getSymbolParent(followedSymbol);
|
||||
if (
|
||||
parent !== undefined && // Make sure the thing we found is a module
|
||||
(parent.flags & ts.SymbolFlags.ValueModule) !== 0
|
||||
) {
|
||||
// Record that that this is an ambient module that can also be imported from
|
||||
this._importableAmbientSourceFiles.add(sourceFile);
|
||||
return parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new InternalError('Unable to determine module for: ' + sourceFile.fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link AstSymbolTable.fetchAstModuleExportInfo}.
|
||||
*/
|
||||
public fetchAstModuleExportInfo(entryPointAstModule: AstModule): AstModuleExportInfo {
|
||||
if (entryPointAstModule.isExternal) {
|
||||
throw new Error('fetchAstModuleExportInfo() is not supported for external modules');
|
||||
}
|
||||
|
||||
if (entryPointAstModule.astModuleExportInfo === undefined) {
|
||||
const astModuleExportInfo: AstModuleExportInfo = new AstModuleExportInfo();
|
||||
|
||||
this._collectAllExportsRecursive(astModuleExportInfo, entryPointAstModule, new Set<AstModule>());
|
||||
|
||||
entryPointAstModule.astModuleExportInfo = astModuleExportInfo;
|
||||
}
|
||||
|
||||
return entryPointAstModule.astModuleExportInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the module specifier refers to an external package. Ignores packages listed in the
|
||||
* "bundledPackages" setting from the api-extractor.json config file.
|
||||
*/
|
||||
private _isExternalModulePath(
|
||||
importOrExportDeclaration: ts.ExportDeclaration | ts.ImportDeclaration | ts.ImportTypeNode,
|
||||
moduleSpecifier: string,
|
||||
): boolean {
|
||||
const specifier: ts.Expression | ts.TypeNode | undefined = ts.isImportTypeNode(importOrExportDeclaration)
|
||||
? importOrExportDeclaration.argument
|
||||
: importOrExportDeclaration.moduleSpecifier;
|
||||
const mode: ts.ModuleKind.CommonJS | ts.ModuleKind.ESNext | undefined =
|
||||
specifier && ts.isStringLiteralLike(specifier)
|
||||
? TypeScriptInternals.getModeForUsageLocation(importOrExportDeclaration.getSourceFile(), specifier)
|
||||
: undefined;
|
||||
|
||||
const resolvedModule: ts.ResolvedModuleFull | undefined = TypeScriptInternals.getResolvedModule(
|
||||
importOrExportDeclaration.getSourceFile(),
|
||||
moduleSpecifier,
|
||||
mode,
|
||||
);
|
||||
|
||||
if (resolvedModule === undefined) {
|
||||
// The TS compiler API `getResolvedModule` cannot resolve ambient modules. Thus, to match API Extractor's
|
||||
// previous behavior, simply treat all ambient modules as external. This bug is tracked by
|
||||
// https://github.com/microsoft/rushstack/issues/3335.
|
||||
return true;
|
||||
}
|
||||
|
||||
// Either something like `jquery` or `@microsoft/api-extractor`.
|
||||
const packageName: string | undefined = resolvedModule.packageId?.name;
|
||||
if (packageName !== undefined && this._bundledPackageNames.has(packageName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (resolvedModule.isExternalLibraryImport === undefined) {
|
||||
// This presumably means the compiler couldn't figure out whether the module was external, but we're not
|
||||
// sure how this can happen.
|
||||
throw new InternalError(
|
||||
`Cannot determine whether the module ${JSON.stringify(moduleSpecifier)} is external\n` +
|
||||
SourceFileLocationFormatter.formatDeclaration(importOrExportDeclaration),
|
||||
);
|
||||
}
|
||||
|
||||
return resolvedModule.isExternalLibraryImport;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if when we analyzed sourceFile, we found that it contains an "export=" statement that allows
|
||||
* it to behave /either/ as an ambient module /or/ as a regular importable module. In this case,
|
||||
* `AstSymbolTable._fetchAstSymbol()` will analyze its symbols even though `TypeScriptHelpers.isAmbient()`
|
||||
* returns true.
|
||||
*/
|
||||
public isImportableAmbientSourceFile(sourceFile: ts.SourceFile): boolean {
|
||||
return this._importableAmbientSourceFiles.has(sourceFile);
|
||||
}
|
||||
|
||||
private _collectAllExportsRecursive(
|
||||
astModuleExportInfo: AstModuleExportInfo,
|
||||
astModule: AstModule,
|
||||
visitedAstModules: Set<AstModule>,
|
||||
): void {
|
||||
if (visitedAstModules.has(astModule)) {
|
||||
return;
|
||||
}
|
||||
|
||||
visitedAstModules.add(astModule);
|
||||
|
||||
if (astModule.isExternal) {
|
||||
astModuleExportInfo.starExportedExternalModules.add(astModule);
|
||||
} else {
|
||||
// Fetch each of the explicit exports for this module
|
||||
if (astModule.moduleSymbol.exports) {
|
||||
for (const [exportName, exportSymbol] of astModule.moduleSymbol.exports.entries()) {
|
||||
switch (exportName) {
|
||||
case ts.InternalSymbolName.ExportStar:
|
||||
case ts.InternalSymbolName.ExportEquals:
|
||||
break;
|
||||
default:
|
||||
// Don't collect the "export default" symbol unless this is the entry point module
|
||||
if (
|
||||
(exportName !== ts.InternalSymbolName.Default || visitedAstModules.size === 1) &&
|
||||
!astModuleExportInfo.exportedLocalEntities.has(exportSymbol.name)
|
||||
) {
|
||||
const astEntity: AstEntity = this._getExportOfAstModule(exportSymbol.name, astModule);
|
||||
|
||||
if (astEntity instanceof AstSymbol && !astEntity.isExternal) {
|
||||
this._astSymbolTable.analyze(astEntity);
|
||||
}
|
||||
|
||||
if (astEntity instanceof AstNamespaceImport && !astEntity.astModule.isExternal) {
|
||||
this._astSymbolTable.analyze(astEntity);
|
||||
}
|
||||
|
||||
astModuleExportInfo.exportedLocalEntities.set(exportSymbol.name, astEntity);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const starExportedModule of astModule.starExportedModules) {
|
||||
this._collectAllExportsRecursive(astModuleExportInfo, starExportedModule, visitedAstModules);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For a given symbol (which was encountered in the specified sourceFile), this fetches the AstEntity that it
|
||||
* refers to. For example, if a particular interface describes the return value of a function, this API can help
|
||||
* us determine a TSDoc declaration reference for that symbol (if the symbol is exported).
|
||||
*/
|
||||
public fetchReferencedAstEntity(symbol: ts.Symbol, referringModuleIsExternal: boolean): AstEntity | undefined {
|
||||
if ((symbol.flags & ts.SymbolFlags.FunctionScopedVariable) !== 0) {
|
||||
// If a symbol refers back to part of its own definition, don't follow that rabbit hole
|
||||
// Example:
|
||||
//
|
||||
// function f(x: number): typeof x {
|
||||
// return 123;
|
||||
// }
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let current: ts.Symbol = symbol;
|
||||
|
||||
if (referringModuleIsExternal) {
|
||||
current = TypeScriptHelpers.followAliases(symbol, this._typeChecker);
|
||||
} else {
|
||||
for (;;) {
|
||||
// Is this symbol an import/export that we need to follow to find the real declaration?
|
||||
for (const declaration of current.declarations ?? []) {
|
||||
let matchedAstEntity: AstEntity | undefined;
|
||||
matchedAstEntity = this._tryMatchExportDeclaration(declaration, current);
|
||||
if (matchedAstEntity !== undefined) {
|
||||
return matchedAstEntity;
|
||||
}
|
||||
|
||||
matchedAstEntity = this._tryMatchImportDeclaration(declaration, current);
|
||||
if (matchedAstEntity !== undefined) {
|
||||
return matchedAstEntity;
|
||||
}
|
||||
}
|
||||
|
||||
if (!(current.flags & ts.SymbolFlags.Alias)) {
|
||||
break;
|
||||
}
|
||||
|
||||
const currentAlias: ts.Symbol = TypeScriptInternals.getImmediateAliasedSymbol(current, this._typeChecker);
|
||||
// Stop if we reach the end of the chain
|
||||
if (!currentAlias || currentAlias === current) {
|
||||
break;
|
||||
}
|
||||
|
||||
current = currentAlias;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, assume it is a normal declaration
|
||||
const astSymbol: AstSymbol | undefined = this._astSymbolTable.fetchAstSymbol({
|
||||
followedSymbol: current,
|
||||
isExternal: referringModuleIsExternal,
|
||||
includeNominalAnalysis: false,
|
||||
addIfMissing: true,
|
||||
});
|
||||
|
||||
return astSymbol;
|
||||
}
|
||||
|
||||
public fetchReferencedAstEntityFromImportTypeNode(
|
||||
node: ts.ImportTypeNode,
|
||||
referringModuleIsExternal: boolean,
|
||||
): AstEntity | undefined {
|
||||
const externalModulePath: string | undefined = this._tryGetExternalModulePath(node);
|
||||
|
||||
if (externalModulePath) {
|
||||
let exportName: string;
|
||||
if (node.qualifier) {
|
||||
// Example input:
|
||||
// import('api-extractor-lib1-test').Lib1GenericType<number>
|
||||
//
|
||||
// Extracted qualifier:
|
||||
// Lib1GenericType
|
||||
exportName = node.qualifier.getText().trim();
|
||||
} else {
|
||||
// Example input:
|
||||
// import('api-extractor-lib1-test')
|
||||
//
|
||||
// Extracted qualifier:
|
||||
// apiExtractorLib1Test
|
||||
|
||||
exportName = SyntaxHelpers.makeCamelCaseIdentifier(externalModulePath);
|
||||
}
|
||||
|
||||
return this._fetchAstImport(undefined, {
|
||||
importKind: AstImportKind.ImportType,
|
||||
exportName,
|
||||
modulePath: externalModulePath,
|
||||
isTypeOnly: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Internal reference: AstSymbol
|
||||
const rightMostToken: ts.Identifier | ts.ImportTypeNode = node.qualifier
|
||||
? node.qualifier.kind === ts.SyntaxKind.QualifiedName
|
||||
? node.qualifier.right
|
||||
: node.qualifier
|
||||
: node;
|
||||
|
||||
// There is no symbol property in a ImportTypeNode, obtain the associated export symbol
|
||||
const exportSymbol: ts.Symbol | undefined = this._typeChecker.getSymbolAtLocation(rightMostToken);
|
||||
if (!exportSymbol) {
|
||||
throw new InternalError(
|
||||
`Symbol not found for identifier: ${node.getText()}\n` + SourceFileLocationFormatter.formatDeclaration(node),
|
||||
);
|
||||
}
|
||||
|
||||
let followedSymbol: ts.Symbol = exportSymbol;
|
||||
for (;;) {
|
||||
const referencedAstEntity: AstEntity | undefined = this.fetchReferencedAstEntity(
|
||||
followedSymbol,
|
||||
referringModuleIsExternal,
|
||||
);
|
||||
|
||||
if (referencedAstEntity) {
|
||||
return referencedAstEntity;
|
||||
}
|
||||
|
||||
const followedSymbolNode: ts.ImportTypeNode | ts.Node | undefined =
|
||||
followedSymbol.declarations && (followedSymbol.declarations[0] as ts.Node | undefined);
|
||||
|
||||
if (followedSymbolNode && followedSymbolNode.kind === ts.SyntaxKind.ImportType) {
|
||||
return this.fetchReferencedAstEntityFromImportTypeNode(
|
||||
followedSymbolNode as ts.ImportTypeNode,
|
||||
referringModuleIsExternal,
|
||||
);
|
||||
}
|
||||
|
||||
if (!(followedSymbol.flags & ts.SymbolFlags.Alias)) {
|
||||
break;
|
||||
}
|
||||
|
||||
const currentAlias: ts.Symbol = this._typeChecker.getAliasedSymbol(followedSymbol);
|
||||
if (!currentAlias || currentAlias === followedSymbol) {
|
||||
break;
|
||||
}
|
||||
|
||||
followedSymbol = currentAlias;
|
||||
}
|
||||
|
||||
const astSymbol: AstSymbol | undefined = this._astSymbolTable.fetchAstSymbol({
|
||||
followedSymbol,
|
||||
isExternal: referringModuleIsExternal,
|
||||
includeNominalAnalysis: false,
|
||||
addIfMissing: true,
|
||||
});
|
||||
|
||||
return astSymbol;
|
||||
}
|
||||
|
||||
private _tryMatchExportDeclaration(declaration: ts.Declaration, declarationSymbol: ts.Symbol): AstEntity | undefined {
|
||||
const exportDeclaration: ts.ExportDeclaration | undefined = TypeScriptHelpers.findFirstParent<ts.ExportDeclaration>(
|
||||
declaration,
|
||||
ts.SyntaxKind.ExportDeclaration,
|
||||
);
|
||||
|
||||
if (exportDeclaration) {
|
||||
let exportName: string | undefined;
|
||||
|
||||
if (declaration.kind === ts.SyntaxKind.ExportSpecifier) {
|
||||
// EXAMPLE:
|
||||
// "export { A } from './file-a';"
|
||||
//
|
||||
// ExportDeclaration:
|
||||
// ExportKeyword: pre=[export] sep=[ ]
|
||||
// NamedExports:
|
||||
// FirstPunctuation: pre=[{] sep=[ ]
|
||||
// SyntaxList:
|
||||
// ExportSpecifier: <------------- declaration
|
||||
// Identifier: pre=[A] sep=[ ]
|
||||
// CloseBraceToken: pre=[}] sep=[ ]
|
||||
// FromKeyword: pre=[from] sep=[ ]
|
||||
// StringLiteral: pre=['./file-a']
|
||||
// SemicolonToken: pre=[;]
|
||||
|
||||
// Example: " ExportName as RenamedName"
|
||||
const exportSpecifier: ts.ExportSpecifier = declaration as ts.ExportSpecifier;
|
||||
exportName = (exportSpecifier.propertyName ?? exportSpecifier.name).getText().trim();
|
||||
} else if (declaration.kind === ts.SyntaxKind.NamespaceExport) {
|
||||
// EXAMPLE:
|
||||
// "export * as theLib from 'the-lib';"
|
||||
//
|
||||
// ExportDeclaration:
|
||||
// ExportKeyword: pre=[export] sep=[ ]
|
||||
// NamespaceExport:
|
||||
// AsteriskToken: pre=[*] sep=[ ]
|
||||
// AsKeyword: pre=[as] sep=[ ]
|
||||
// Identifier: pre=[theLib] sep=[ ]
|
||||
// FromKeyword: pre=[from] sep=[ ]
|
||||
// StringLiteral: pre=['the-lib']
|
||||
// SemicolonToken: pre=[;]
|
||||
|
||||
// Issue tracking this feature: https://github.com/microsoft/rushstack/issues/2780
|
||||
throw new Error(
|
||||
`The "export * as ___" syntax is not supported yet; as a workaround,` +
|
||||
` use "import * as ___" with a separate "export { ___ }" declaration\n` +
|
||||
SourceFileLocationFormatter.formatDeclaration(declaration),
|
||||
);
|
||||
} else {
|
||||
throw new InternalError(
|
||||
`Unimplemented export declaration kind: ${declaration.getText()}\n` +
|
||||
SourceFileLocationFormatter.formatDeclaration(declaration),
|
||||
);
|
||||
}
|
||||
|
||||
// Ignore "export { A }" without a module specifier
|
||||
if (exportDeclaration.moduleSpecifier) {
|
||||
const externalModulePath: string | undefined = this._tryGetExternalModulePath(exportDeclaration);
|
||||
|
||||
if (externalModulePath !== undefined) {
|
||||
return this._fetchAstImport(declarationSymbol, {
|
||||
importKind: AstImportKind.NamedImport,
|
||||
modulePath: externalModulePath,
|
||||
exportName,
|
||||
isTypeOnly: false,
|
||||
});
|
||||
}
|
||||
|
||||
return this._getExportOfSpecifierAstModule(exportName, exportDeclaration, declarationSymbol);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private _tryMatchImportDeclaration(declaration: ts.Declaration, declarationSymbol: ts.Symbol): AstEntity | undefined {
|
||||
const importDeclaration: ts.ImportDeclaration | undefined = TypeScriptHelpers.findFirstParent<ts.ImportDeclaration>(
|
||||
declaration,
|
||||
ts.SyntaxKind.ImportDeclaration,
|
||||
);
|
||||
|
||||
if (importDeclaration) {
|
||||
const externalModulePath: string | undefined = this._tryGetExternalModulePath(importDeclaration);
|
||||
|
||||
if (declaration.kind === ts.SyntaxKind.NamespaceImport) {
|
||||
// EXAMPLE:
|
||||
// "import * as theLib from 'the-lib';"
|
||||
//
|
||||
// ImportDeclaration:
|
||||
// ImportKeyword: pre=[import] sep=[ ]
|
||||
// ImportClause:
|
||||
// NamespaceImport: <------------- declaration
|
||||
// AsteriskToken: pre=[*] sep=[ ]
|
||||
// AsKeyword: pre=[as] sep=[ ]
|
||||
// Identifier: pre=[theLib] sep=[ ]
|
||||
// FromKeyword: pre=[from] sep=[ ]
|
||||
// StringLiteral: pre=['the-lib']
|
||||
// SemicolonToken: pre=[;]
|
||||
|
||||
if (externalModulePath === undefined) {
|
||||
const astModule: AstModule = this._fetchSpecifierAstModule(importDeclaration, declarationSymbol);
|
||||
let namespaceImport: AstNamespaceImport | undefined = this._astNamespaceImportByModule.get(astModule);
|
||||
if (namespaceImport === undefined) {
|
||||
namespaceImport = new AstNamespaceImport({
|
||||
namespaceName: declarationSymbol.name,
|
||||
astModule,
|
||||
declaration,
|
||||
symbol: declarationSymbol,
|
||||
});
|
||||
this._astNamespaceImportByModule.set(astModule, namespaceImport);
|
||||
}
|
||||
|
||||
return namespaceImport;
|
||||
}
|
||||
|
||||
// Here importSymbol=undefined because {@inheritDoc} and such are not going to work correctly for
|
||||
// a package or source file.
|
||||
return this._fetchAstImport(undefined, {
|
||||
importKind: AstImportKind.StarImport,
|
||||
exportName: declarationSymbol.name,
|
||||
modulePath: externalModulePath,
|
||||
isTypeOnly: ExportAnalyzer._getIsTypeOnly(importDeclaration),
|
||||
});
|
||||
}
|
||||
|
||||
if (declaration.kind === ts.SyntaxKind.ImportSpecifier) {
|
||||
// EXAMPLE:
|
||||
// "import { A, B } from 'the-lib';"
|
||||
//
|
||||
// ImportDeclaration:
|
||||
// ImportKeyword: pre=[import] sep=[ ]
|
||||
// ImportClause:
|
||||
// NamedImports:
|
||||
// FirstPunctuation: pre=[{] sep=[ ]
|
||||
// SyntaxList:
|
||||
// ImportSpecifier: <------------- declaration
|
||||
// Identifier: pre=[A]
|
||||
// CommaToken: pre=[,] sep=[ ]
|
||||
// ImportSpecifier:
|
||||
// Identifier: pre=[B] sep=[ ]
|
||||
// CloseBraceToken: pre=[}] sep=[ ]
|
||||
// FromKeyword: pre=[from] sep=[ ]
|
||||
// StringLiteral: pre=['the-lib']
|
||||
// SemicolonToken: pre=[;]
|
||||
|
||||
// Example: " ExportName as RenamedName"
|
||||
const importSpecifier: ts.ImportSpecifier = declaration as ts.ImportSpecifier;
|
||||
const exportName: string = (importSpecifier.propertyName ?? importSpecifier.name).getText().trim();
|
||||
|
||||
if (externalModulePath !== undefined) {
|
||||
return this._fetchAstImport(declarationSymbol, {
|
||||
importKind: AstImportKind.NamedImport,
|
||||
modulePath: externalModulePath,
|
||||
exportName,
|
||||
isTypeOnly: ExportAnalyzer._getIsTypeOnly(importDeclaration),
|
||||
});
|
||||
}
|
||||
|
||||
return this._getExportOfSpecifierAstModule(exportName, importDeclaration, declarationSymbol);
|
||||
} else if (declaration.kind === ts.SyntaxKind.ImportClause) {
|
||||
// EXAMPLE:
|
||||
// "import A, { B } from './A';"
|
||||
//
|
||||
// ImportDeclaration:
|
||||
// ImportKeyword: pre=[import] sep=[ ]
|
||||
// ImportClause: <------------- declaration (referring to A)
|
||||
// Identifier: pre=[A]
|
||||
// CommaToken: pre=[,] sep=[ ]
|
||||
// NamedImports:
|
||||
// FirstPunctuation: pre=[{] sep=[ ]
|
||||
// SyntaxList:
|
||||
// ImportSpecifier:
|
||||
// Identifier: pre=[B] sep=[ ]
|
||||
// CloseBraceToken: pre=[}] sep=[ ]
|
||||
// FromKeyword: pre=[from] sep=[ ]
|
||||
// StringLiteral: pre=['./A']
|
||||
// SemicolonToken: pre=[;]
|
||||
|
||||
const importClause: ts.ImportClause = declaration as ts.ImportClause;
|
||||
const exportName: string = importClause.name
|
||||
? importClause.name.getText().trim()
|
||||
: ts.InternalSymbolName.Default;
|
||||
|
||||
if (externalModulePath !== undefined) {
|
||||
return this._fetchAstImport(declarationSymbol, {
|
||||
importKind: AstImportKind.DefaultImport,
|
||||
modulePath: externalModulePath,
|
||||
exportName,
|
||||
isTypeOnly: ExportAnalyzer._getIsTypeOnly(importDeclaration),
|
||||
});
|
||||
}
|
||||
|
||||
return this._getExportOfSpecifierAstModule(ts.InternalSymbolName.Default, importDeclaration, declarationSymbol);
|
||||
} else {
|
||||
throw new InternalError(
|
||||
`Unimplemented import declaration kind: ${declaration.getText()}\n` +
|
||||
SourceFileLocationFormatter.formatDeclaration(declaration),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
ts.isImportEqualsDeclaration(declaration) && // EXAMPLE:
|
||||
// import myLib = require('my-lib');
|
||||
//
|
||||
// ImportEqualsDeclaration:
|
||||
// ImportKeyword: pre=[import] sep=[ ]
|
||||
// Identifier: pre=[myLib] sep=[ ]
|
||||
// FirstAssignment: pre=[=] sep=[ ]
|
||||
// ExternalModuleReference:
|
||||
// RequireKeyword: pre=[require]
|
||||
// OpenParenToken: pre=[(]
|
||||
// StringLiteral: pre=['my-lib']
|
||||
// CloseParenToken: pre=[)]
|
||||
// SemicolonToken: pre=[;]
|
||||
ts.isExternalModuleReference(declaration.moduleReference) &&
|
||||
ts.isStringLiteralLike(declaration.moduleReference.expression)
|
||||
) {
|
||||
const variableName: string = TypeScriptInternals.getTextOfIdentifierOrLiteral(declaration.name);
|
||||
const externalModuleName: string = TypeScriptInternals.getTextOfIdentifierOrLiteral(
|
||||
declaration.moduleReference.expression,
|
||||
);
|
||||
|
||||
return this._fetchAstImport(declarationSymbol, {
|
||||
importKind: AstImportKind.EqualsImport,
|
||||
modulePath: externalModuleName,
|
||||
exportName: variableName,
|
||||
isTypeOnly: false,
|
||||
});
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static _getIsTypeOnly(importDeclaration: ts.ImportDeclaration): boolean {
|
||||
if (importDeclaration.importClause) {
|
||||
return Boolean(importDeclaration.importClause.isTypeOnly);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private _getExportOfSpecifierAstModule(
|
||||
exportName: string,
|
||||
importOrExportDeclaration: ts.ExportDeclaration | ts.ImportDeclaration,
|
||||
exportSymbol: ts.Symbol,
|
||||
): AstEntity {
|
||||
const specifierAstModule: AstModule = this._fetchSpecifierAstModule(importOrExportDeclaration, exportSymbol);
|
||||
const astEntity: AstEntity = this._getExportOfAstModule(exportName, specifierAstModule);
|
||||
return astEntity;
|
||||
}
|
||||
|
||||
private _getExportOfAstModule(exportName: string, astModule: AstModule): AstEntity {
|
||||
const visitedAstModules: Set<AstModule> = new Set<AstModule>();
|
||||
const astEntity: AstEntity | undefined = this._tryGetExportOfAstModule(exportName, astModule, visitedAstModules);
|
||||
if (astEntity === undefined) {
|
||||
throw new InternalError(
|
||||
`Unable to analyze the export ${JSON.stringify(exportName)} in\n` + astModule.sourceFile.fileName,
|
||||
);
|
||||
}
|
||||
|
||||
return astEntity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of {@link AstSymbolTable.tryGetExportOfAstModule}.
|
||||
*/
|
||||
public tryGetExportOfAstModule(exportName: string, astModule: AstModule): AstEntity | undefined {
|
||||
const visitedAstModules: Set<AstModule> = new Set<AstModule>();
|
||||
return this._tryGetExportOfAstModule(exportName, astModule, visitedAstModules);
|
||||
}
|
||||
|
||||
private _tryGetExportOfAstModule(
|
||||
exportName: string,
|
||||
astModule: AstModule,
|
||||
visitedAstModules: Set<AstModule>,
|
||||
): AstEntity | undefined {
|
||||
if (visitedAstModules.has(astModule)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
visitedAstModules.add(astModule);
|
||||
|
||||
let astEntity: AstEntity | undefined = astModule.cachedExportedEntities.get(exportName);
|
||||
if (astEntity !== undefined) {
|
||||
return astEntity;
|
||||
}
|
||||
|
||||
// Try the explicit exports
|
||||
const escapedExportName: ts.__String = ts.escapeLeadingUnderscores(exportName);
|
||||
if (astModule.moduleSymbol.exports) {
|
||||
const exportSymbol: ts.Symbol | undefined = astModule.moduleSymbol.exports.get(escapedExportName);
|
||||
if (exportSymbol) {
|
||||
astEntity = this.fetchReferencedAstEntity(exportSymbol, astModule.isExternal);
|
||||
|
||||
if (astEntity !== undefined) {
|
||||
astModule.cachedExportedEntities.set(exportName, astEntity); // cache for next time
|
||||
return astEntity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try each of the star imports
|
||||
for (const starExportedModule of astModule.starExportedModules) {
|
||||
astEntity = this._tryGetExportOfAstModule(exportName, starExportedModule, visitedAstModules);
|
||||
|
||||
if (astEntity !== undefined) {
|
||||
if (starExportedModule.externalModulePath !== undefined) {
|
||||
// This entity was obtained from an external module, so return an AstImport instead
|
||||
const astSymbol: AstSymbol = astEntity as AstSymbol;
|
||||
return this._fetchAstImport(astSymbol.followedSymbol, {
|
||||
importKind: AstImportKind.NamedImport,
|
||||
modulePath: starExportedModule.externalModulePath,
|
||||
exportName,
|
||||
isTypeOnly: false,
|
||||
});
|
||||
}
|
||||
|
||||
return astEntity;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private _tryGetExternalModulePath(
|
||||
importOrExportDeclaration: ts.ExportDeclaration | ts.ImportDeclaration | ts.ImportTypeNode,
|
||||
): string | undefined {
|
||||
const moduleSpecifier: string = this._getModuleSpecifier(importOrExportDeclaration);
|
||||
if (this._isExternalModulePath(importOrExportDeclaration, moduleSpecifier)) {
|
||||
return moduleSpecifier;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an ImportDeclaration of the form `export { X } from "___";`, this interprets the module specifier (`"___"`)
|
||||
* and fetches the corresponding AstModule object.
|
||||
*/
|
||||
private _fetchSpecifierAstModule(
|
||||
importOrExportDeclaration: ts.ExportDeclaration | ts.ImportDeclaration,
|
||||
exportSymbol: ts.Symbol,
|
||||
): AstModule {
|
||||
const moduleSpecifier: string = this._getModuleSpecifier(importOrExportDeclaration);
|
||||
const mode: ts.ModuleKind.CommonJS | ts.ModuleKind.ESNext | undefined =
|
||||
importOrExportDeclaration.moduleSpecifier && ts.isStringLiteralLike(importOrExportDeclaration.moduleSpecifier)
|
||||
? TypeScriptInternals.getModeForUsageLocation(
|
||||
importOrExportDeclaration.getSourceFile(),
|
||||
importOrExportDeclaration.moduleSpecifier,
|
||||
)
|
||||
: undefined;
|
||||
const resolvedModule: ts.ResolvedModuleFull | undefined = TypeScriptInternals.getResolvedModule(
|
||||
importOrExportDeclaration.getSourceFile(),
|
||||
moduleSpecifier,
|
||||
mode,
|
||||
);
|
||||
|
||||
if (resolvedModule === undefined) {
|
||||
// Encountered in https://github.com/microsoft/rushstack/issues/1914.
|
||||
//
|
||||
// It's also possible for this to occur with ambient modules. However, in practice this doesn't happen
|
||||
// as API Extractor treats all ambient modules as external per the logic in `_isExternalModulePath`, and
|
||||
// thus this code path is never reached for ambient modules.
|
||||
throw new InternalError(
|
||||
`getResolvedModule() could not resolve module name ${JSON.stringify(moduleSpecifier)}\n` +
|
||||
SourceFileLocationFormatter.formatDeclaration(importOrExportDeclaration),
|
||||
);
|
||||
}
|
||||
|
||||
// Map the filename back to the corresponding SourceFile. This circuitous approach is needed because
|
||||
// we have no way to access the compiler's internal resolveExternalModuleName() function
|
||||
const moduleSourceFile: ts.SourceFile | undefined = this._program.getSourceFile(resolvedModule.resolvedFileName);
|
||||
if (!moduleSourceFile) {
|
||||
// This should not happen, since getResolvedModule() specifically looks up names that the compiler
|
||||
// found in export declarations for this source file
|
||||
throw new InternalError(
|
||||
`getSourceFile() failed to locate ${JSON.stringify(resolvedModule.resolvedFileName)}\n` +
|
||||
SourceFileLocationFormatter.formatDeclaration(importOrExportDeclaration),
|
||||
);
|
||||
}
|
||||
|
||||
const isExternal: boolean = this._isExternalModulePath(importOrExportDeclaration, moduleSpecifier);
|
||||
const moduleReference: IAstModuleReference = {
|
||||
moduleSpecifier,
|
||||
moduleSpecifierSymbol: exportSymbol,
|
||||
};
|
||||
const specifierAstModule: AstModule = this.fetchAstModuleFromSourceFile(
|
||||
moduleSourceFile,
|
||||
moduleReference,
|
||||
isExternal,
|
||||
);
|
||||
|
||||
return specifierAstModule;
|
||||
}
|
||||
|
||||
private _fetchAstImport(importSymbol: ts.Symbol | undefined, options: IAstImportOptions): AstImport {
|
||||
const key: string = AstImport.getKey(options);
|
||||
|
||||
let astImport: AstImport | undefined = this._astImportsByKey.get(key);
|
||||
|
||||
if (astImport) {
|
||||
// If we encounter at least one import that does not use the type-only form,
|
||||
// then the .d.ts rollup will NOT use "import type".
|
||||
if (!options.isTypeOnly) {
|
||||
astImport.isTypeOnlyEverywhere = false;
|
||||
}
|
||||
} else {
|
||||
astImport = new AstImport(options);
|
||||
this._astImportsByKey.set(key, astImport);
|
||||
|
||||
if (importSymbol) {
|
||||
const followedSymbol: ts.Symbol = TypeScriptHelpers.followAliases(importSymbol, this._typeChecker);
|
||||
|
||||
astImport.astSymbol = this._astSymbolTable.fetchAstSymbol({
|
||||
followedSymbol,
|
||||
isExternal: true,
|
||||
includeNominalAnalysis: false,
|
||||
addIfMissing: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return astImport;
|
||||
}
|
||||
|
||||
private _getModuleSpecifier(
|
||||
importOrExportDeclaration: ts.ExportDeclaration | ts.ImportDeclaration | ts.ImportTypeNode,
|
||||
): string {
|
||||
// The name of the module, which could be like "./SomeLocalFile' or like 'external-package/entry/point'
|
||||
const moduleSpecifier: string | undefined = TypeScriptHelpers.getModuleSpecifier(importOrExportDeclaration);
|
||||
|
||||
if (!moduleSpecifier) {
|
||||
throw new InternalError(
|
||||
'Unable to parse module specifier\n' + SourceFileLocationFormatter.formatDeclaration(importOrExportDeclaration),
|
||||
);
|
||||
}
|
||||
|
||||
return moduleSpecifier;
|
||||
}
|
||||
}
|
||||
201
packages/api-extractor/src/analyzer/PackageMetadataManager.ts
Normal file
201
packages/api-extractor/src/analyzer/PackageMetadataManager.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
type PackageJsonLookup,
|
||||
FileSystem,
|
||||
JsonFile,
|
||||
type NewlineKind,
|
||||
type INodePackageJson,
|
||||
type JsonObject,
|
||||
} from '@rushstack/node-core-library';
|
||||
import { ConsoleMessageId } from '../api/ConsoleMessageId.js';
|
||||
import { Extractor } from '../api/Extractor.js';
|
||||
import type { MessageRouter } from '../collector/MessageRouter.js';
|
||||
|
||||
/**
|
||||
* Represents analyzed information for a package.json file.
|
||||
* This object is constructed and returned by PackageMetadataManager.
|
||||
*/
|
||||
export class PackageMetadata {
|
||||
/**
|
||||
* The absolute path to the package.json file being analyzed.
|
||||
*/
|
||||
public readonly packageJsonPath: string;
|
||||
|
||||
/**
|
||||
* The parsed contents of package.json. Note that PackageJsonLookup
|
||||
* only includes essential fields.
|
||||
*/
|
||||
public readonly packageJson: INodePackageJson;
|
||||
|
||||
/**
|
||||
* If true, then the package's documentation comments can be assumed
|
||||
* to contain API Extractor compatible TSDoc tags.
|
||||
*/
|
||||
public readonly aedocSupported: boolean;
|
||||
|
||||
public constructor(packageJsonPath: string, packageJson: INodePackageJson, aedocSupported: boolean) {
|
||||
this.packageJsonPath = packageJsonPath;
|
||||
this.packageJson = packageJson;
|
||||
this.aedocSupported = aedocSupported;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This class maintains a cache of analyzed information obtained from package.json
|
||||
* files. It is built on top of the PackageJsonLookup class.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* IMPORTANT: Don't use PackageMetadataManager to analyze source files from the current project:
|
||||
* 1. Files such as tsdoc-metadata.json may not have been built yet, and thus may contain incorrect information.
|
||||
* 2. The current project is not guaranteed to have a package.json file at all. For example, API Extractor can
|
||||
* be invoked on a bare .d.ts file.
|
||||
*
|
||||
* Use ts.program.isSourceFileFromExternalLibrary() to test source files before passing the to PackageMetadataManager.
|
||||
*/
|
||||
export class PackageMetadataManager {
|
||||
public static tsdocMetadataFilename: string = 'tsdoc-metadata.json';
|
||||
|
||||
private readonly _packageJsonLookup: PackageJsonLookup;
|
||||
|
||||
private readonly _messageRouter: MessageRouter;
|
||||
|
||||
private readonly _packageMetadataByPackageJsonPath: Map<string, PackageMetadata> = new Map<string, PackageMetadata>();
|
||||
|
||||
public constructor(packageJsonLookup: PackageJsonLookup, messageRouter: MessageRouter) {
|
||||
this._packageJsonLookup = packageJsonLookup;
|
||||
this._messageRouter = messageRouter;
|
||||
}
|
||||
|
||||
// This feature is still being standardized: https://github.com/microsoft/tsdoc/issues/7
|
||||
// In the future we will use the @microsoft/tsdoc library to read this file.
|
||||
private static _resolveTsdocMetadataPathFromPackageJson(
|
||||
packageFolder: string,
|
||||
packageJson: INodePackageJson,
|
||||
): string {
|
||||
const tsdocMetadataFilename: string = PackageMetadataManager.tsdocMetadataFilename;
|
||||
|
||||
let tsdocMetadataRelativePath: string;
|
||||
|
||||
if (packageJson.tsdocMetadata) {
|
||||
// 1. If package.json contains a field such as "tsdocMetadata": "./path1/path2/tsdoc-metadata.json",
|
||||
// then that takes precedence. This convention will be rarely needed, since the other rules below generally
|
||||
// produce a good result.
|
||||
tsdocMetadataRelativePath = packageJson.tsdocMetadata;
|
||||
} else if (packageJson.typings) {
|
||||
// 2. If package.json contains a field such as "typings": "./path1/path2/index.d.ts", then we look
|
||||
// for the file under "./path1/path2/tsdoc-metadata.json"
|
||||
tsdocMetadataRelativePath = path.join(path.dirname(packageJson.typings), tsdocMetadataFilename);
|
||||
} else if (packageJson.main) {
|
||||
// 3. If package.json contains a field such as "main": "./path1/path2/index.js", then we look for
|
||||
// the file under "./path1/path2/tsdoc-metadata.json"
|
||||
tsdocMetadataRelativePath = path.join(path.dirname(packageJson.main), tsdocMetadataFilename);
|
||||
} else {
|
||||
// 4. If none of the above rules apply, then by default we look for the file under "./tsdoc-metadata.json"
|
||||
// since the default entry point is "./index.js"
|
||||
tsdocMetadataRelativePath = tsdocMetadataFilename;
|
||||
}
|
||||
|
||||
// Always resolve relative to the package folder.
|
||||
const tsdocMetadataPath: string = path.resolve(packageFolder, tsdocMetadataRelativePath);
|
||||
return tsdocMetadataPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param packageFolder - The package folder
|
||||
* @param packageJson - The package JSON
|
||||
* @param tsdocMetadataPath - An explicit path that can be configured in api-extractor.json.
|
||||
* If this parameter is not an empty string, it overrides the normal path calculation.
|
||||
* @returns the absolute path to the TSDoc metadata file
|
||||
*/
|
||||
public static resolveTsdocMetadataPath(
|
||||
packageFolder: string,
|
||||
packageJson: INodePackageJson,
|
||||
tsdocMetadataPath?: string,
|
||||
): string {
|
||||
if (tsdocMetadataPath) {
|
||||
return path.resolve(packageFolder, tsdocMetadataPath);
|
||||
}
|
||||
|
||||
return PackageMetadataManager._resolveTsdocMetadataPathFromPackageJson(packageFolder, packageJson);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the TSDoc metadata file to the specified output file.
|
||||
*/
|
||||
public static writeTsdocMetadataFile(tsdocMetadataPath: string, newlineKind: NewlineKind): void {
|
||||
const fileObject: JsonObject = {
|
||||
tsdocVersion: '0.12',
|
||||
toolPackages: [
|
||||
{
|
||||
packageName: '@microsoft/api-extractor',
|
||||
packageVersion: Extractor.version,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const fileContent: string =
|
||||
'// This file is read by tools that parse documentation comments conforming to the TSDoc standard.\n' +
|
||||
'// It should be published with your NPM package. It should not be tracked by Git.\n' +
|
||||
JsonFile.stringify(fileObject);
|
||||
|
||||
FileSystem.writeFile(tsdocMetadataPath, fileContent, {
|
||||
convertLineEndings: newlineKind,
|
||||
ensureFolderExists: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the package.json in a parent folder of the specified source file, and
|
||||
* returns a PackageMetadata object. If no package.json was found, then undefined
|
||||
* is returned. The results are cached.
|
||||
*/
|
||||
public tryFetchPackageMetadata(sourceFilePath: string): PackageMetadata | undefined {
|
||||
const packageJsonFilePath: string | undefined =
|
||||
this._packageJsonLookup.tryGetPackageJsonFilePathFor(sourceFilePath);
|
||||
if (!packageJsonFilePath) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let packageMetadata: PackageMetadata | undefined = this._packageMetadataByPackageJsonPath.get(packageJsonFilePath);
|
||||
|
||||
if (!packageMetadata) {
|
||||
const packageJson: INodePackageJson = this._packageJsonLookup.loadNodePackageJson(packageJsonFilePath);
|
||||
|
||||
const packageJsonFolder: string = path.dirname(packageJsonFilePath);
|
||||
|
||||
let aedocSupported = false;
|
||||
|
||||
const tsdocMetadataPath: string = PackageMetadataManager._resolveTsdocMetadataPathFromPackageJson(
|
||||
packageJsonFolder,
|
||||
packageJson,
|
||||
);
|
||||
|
||||
if (FileSystem.exists(tsdocMetadataPath)) {
|
||||
this._messageRouter.logVerbose(ConsoleMessageId.FoundTSDocMetadata, 'Found metadata in ' + tsdocMetadataPath);
|
||||
// If the file exists at all, assume it was written by API Extractor
|
||||
aedocSupported = true;
|
||||
}
|
||||
|
||||
packageMetadata = new PackageMetadata(packageJsonFilePath, packageJson, aedocSupported);
|
||||
this._packageMetadataByPackageJsonPath.set(packageJsonFilePath, packageMetadata);
|
||||
}
|
||||
|
||||
return packageMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the source file is part of a package whose .d.ts files support AEDoc annotations.
|
||||
*/
|
||||
public isAedocSupportedFor(sourceFilePath: string): boolean {
|
||||
const packageMetadata: PackageMetadata | undefined = this.tryFetchPackageMetadata(sourceFilePath);
|
||||
if (!packageMetadata) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return packageMetadata.aedocSupported;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
import * as path from 'node:path';
|
||||
import { Path, Text } from '@rushstack/node-core-library';
|
||||
import type * as ts from 'typescript';
|
||||
|
||||
export interface ISourceFileLocationFormatOptions {
|
||||
sourceFileColumn?: number | undefined;
|
||||
sourceFileLine?: number | undefined;
|
||||
workingPackageFolderPath?: string | undefined;
|
||||
}
|
||||
|
||||
export class SourceFileLocationFormatter {
|
||||
/**
|
||||
* Returns a string such as this, based on the context information in the provided node:
|
||||
* "[C:\\Folder\\File.ts#123]"
|
||||
*/
|
||||
public static formatDeclaration(node: ts.Node, workingPackageFolderPath?: string): string {
|
||||
const sourceFile: ts.SourceFile = node.getSourceFile();
|
||||
const lineAndCharacter: ts.LineAndCharacter = sourceFile.getLineAndCharacterOfPosition(node.getStart());
|
||||
|
||||
return SourceFileLocationFormatter.formatPath(sourceFile.fileName, {
|
||||
sourceFileLine: lineAndCharacter.line + 1,
|
||||
sourceFileColumn: lineAndCharacter.character + 1,
|
||||
workingPackageFolderPath,
|
||||
});
|
||||
}
|
||||
|
||||
public static formatPath(sourceFilePath: string, options?: ISourceFileLocationFormatOptions): string {
|
||||
const ioptions = options ?? {};
|
||||
|
||||
let result = '';
|
||||
|
||||
// Make the path relative to the workingPackageFolderPath
|
||||
let scrubbedPath: string = sourceFilePath;
|
||||
|
||||
if (
|
||||
ioptions.workingPackageFolderPath && // If it's under the working folder, make it a relative path
|
||||
Path.isUnderOrEqual(sourceFilePath, ioptions.workingPackageFolderPath)
|
||||
) {
|
||||
scrubbedPath = path.relative(ioptions.workingPackageFolderPath, sourceFilePath);
|
||||
}
|
||||
|
||||
// Convert it to a Unix-style path
|
||||
scrubbedPath = Text.replaceAll(scrubbedPath, '\\', '/');
|
||||
result += scrubbedPath;
|
||||
|
||||
if (ioptions.sourceFileLine) {
|
||||
result += `:${ioptions.sourceFileLine}`;
|
||||
|
||||
if (ioptions.sourceFileColumn) {
|
||||
result += `:${ioptions.sourceFileColumn}`;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
683
packages/api-extractor/src/analyzer/Span.ts
Normal file
683
packages/api-extractor/src/analyzer/Span.ts
Normal file
@@ -0,0 +1,683 @@
|
||||
/* eslint-disable promise/prefer-await-to-callbacks */
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
import { InternalError, Sort } from '@rushstack/node-core-library';
|
||||
import * as ts from 'typescript';
|
||||
import { IndentedWriter } from '../generators/IndentedWriter.js';
|
||||
|
||||
interface IWriteModifiedTextOptions {
|
||||
indentDocCommentState: IndentDocCommentState;
|
||||
separatorOverride: string | undefined;
|
||||
writer: IndentedWriter;
|
||||
}
|
||||
|
||||
enum IndentDocCommentState {
|
||||
/**
|
||||
* `indentDocComment` was not requested for this subtree.
|
||||
*/
|
||||
Inactive = 0,
|
||||
/**
|
||||
* `indentDocComment` was requested and we are looking for the opening `/` `*`
|
||||
*/
|
||||
AwaitingOpenDelimiter = 1,
|
||||
/**
|
||||
* `indentDocComment` was requested and we are looking for the closing `*` `/`
|
||||
*/
|
||||
AwaitingCloseDelimiter = 2,
|
||||
/**
|
||||
* `indentDocComment` was requested and we have finished indenting the comment.
|
||||
*/
|
||||
Done = 3,
|
||||
}
|
||||
|
||||
/**
|
||||
* Choices for SpanModification.indentDocComment.
|
||||
*/
|
||||
export enum IndentDocCommentScope {
|
||||
/**
|
||||
* Do not detect and indent comments.
|
||||
*/
|
||||
None = 0,
|
||||
|
||||
/**
|
||||
* Look for one doc comment in the {@link Span.prefix} text only.
|
||||
*/
|
||||
PrefixOnly = 1,
|
||||
|
||||
/**
|
||||
* Look for one doc comment potentially distributed across the Span and its children.
|
||||
*/
|
||||
SpanAndChildren = 2,
|
||||
}
|
||||
|
||||
/**
|
||||
* Specifies various transformations that will be performed by Span.getModifiedText().
|
||||
*/
|
||||
export class SpanModification {
|
||||
/**
|
||||
* If true, all of the child spans will be omitted from the Span.getModifiedText() output.
|
||||
*
|
||||
* @remarks
|
||||
* Also, the modify() operation will not recurse into these spans.
|
||||
*/
|
||||
public omitChildren: boolean = false;
|
||||
|
||||
/**
|
||||
* If true, then the Span.separator will be removed from the Span.getModifiedText() output.
|
||||
*/
|
||||
public omitSeparatorAfter: boolean = false;
|
||||
|
||||
/**
|
||||
* If true, then Span.getModifiedText() will sort the immediate children according to their Span.sortKey
|
||||
* property. The separators will also be fixed up to ensure correct indentation. If the Span.sortKey is undefined
|
||||
* for some items, those items will not be moved, i.e. their array indexes will be unchanged.
|
||||
*/
|
||||
public sortChildren: boolean = false;
|
||||
|
||||
/**
|
||||
* Used if the parent span has Span.sortChildren=true.
|
||||
*/
|
||||
public sortKey: string | undefined;
|
||||
|
||||
/**
|
||||
* Optionally configures getModifiedText() to search for a "/*" doc comment and indent it.
|
||||
* At most one comment is detected.
|
||||
*
|
||||
* @remarks
|
||||
* The indentation can be applied to the `Span.modifier.prefix` only, or it can be applied to the
|
||||
* full subtree of nodes (as needed for `ts.SyntaxKind.JSDocComment` trees). However the enabled
|
||||
* scopes must not overlap.
|
||||
*
|
||||
* This feature is enabled selectively because (1) we do not want to accidentally match `/*` appearing
|
||||
* in a string literal or other expression that is not a comment, and (2) parsing comments is relatively
|
||||
* expensive.
|
||||
*/
|
||||
public indentDocComment: IndentDocCommentScope = IndentDocCommentScope.None;
|
||||
|
||||
private readonly _span: Span;
|
||||
|
||||
private _prefix: string | undefined;
|
||||
|
||||
private _suffix: string | undefined;
|
||||
|
||||
public constructor(span: Span) {
|
||||
this._span = span;
|
||||
this.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows the Span.prefix text to be changed.
|
||||
*/
|
||||
public get prefix(): string {
|
||||
return this._prefix ?? this._span.prefix;
|
||||
}
|
||||
|
||||
public set prefix(value: string) {
|
||||
this._prefix = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows the Span.suffix text to be changed.
|
||||
*/
|
||||
public get suffix(): string {
|
||||
return this._suffix ?? this._span.suffix;
|
||||
}
|
||||
|
||||
public set suffix(value: string) {
|
||||
this._suffix = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverts any modifications made to this object.
|
||||
*/
|
||||
public reset(): void {
|
||||
this.omitChildren = false;
|
||||
this.omitSeparatorAfter = false;
|
||||
this.sortChildren = false;
|
||||
this.sortKey = undefined;
|
||||
this._prefix = undefined;
|
||||
this._suffix = undefined;
|
||||
if (this._span.kind === ts.SyntaxKind.JSDocComment) {
|
||||
this.indentDocComment = IndentDocCommentScope.SpanAndChildren;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Effectively deletes the Span from the tree, by skipping its children, skipping its separator,
|
||||
* and setting its prefix/suffix to the empty string.
|
||||
*/
|
||||
public skipAll(): void {
|
||||
this.prefix = '';
|
||||
this.suffix = '';
|
||||
this.omitChildren = true;
|
||||
this.omitSeparatorAfter = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Span class provides a simple way to rewrite TypeScript source files
|
||||
* based on simple syntax transformations, i.e. without having to process deeper aspects
|
||||
* of the underlying grammar. An example transformation might be deleting JSDoc comments
|
||||
* from a source file.
|
||||
*
|
||||
* @remarks
|
||||
* TypeScript's abstract syntax tree (AST) is represented using Node objects.
|
||||
* The Node text ignores its surrounding whitespace, and does not have an ordering guarantee.
|
||||
* For example, a JSDocComment node can be a child of a FunctionDeclaration node, even though
|
||||
* the actual comment precedes the function in the input stream.
|
||||
*
|
||||
* The Span class is a wrapper for a single Node, that provides access to every character
|
||||
* in the input stream, such that Span.getText() will exactly reproduce the corresponding
|
||||
* full Node.getText() output.
|
||||
*
|
||||
* A Span is comprised of these parts, which appear in sequential order:
|
||||
* - A prefix
|
||||
* - A collection of child spans
|
||||
* - A suffix
|
||||
* - A separator (e.g. whitespace between this span and the next item in the tree)
|
||||
*
|
||||
* These parts can be modified via Span.modification. The modification is applied by
|
||||
* calling Span.getModifiedText().
|
||||
*/
|
||||
export class Span {
|
||||
public readonly node: ts.Node;
|
||||
|
||||
// To improve performance, substrings are not allocated until actually needed
|
||||
public readonly startIndex: number;
|
||||
|
||||
public readonly endIndex: number;
|
||||
|
||||
public readonly children: Span[];
|
||||
|
||||
public readonly modification: SpanModification;
|
||||
|
||||
private readonly _parent: Span | undefined;
|
||||
|
||||
private readonly _previousSibling: Span | undefined;
|
||||
|
||||
private readonly _nextSibling: Span | undefined;
|
||||
|
||||
private readonly _separatorStartIndex: number;
|
||||
|
||||
private readonly _separatorEndIndex: number;
|
||||
|
||||
public constructor(node: ts.Node) {
|
||||
this.node = node;
|
||||
this.startIndex = node.kind === ts.SyntaxKind.SourceFile ? node.getFullStart() : node.getStart();
|
||||
this.endIndex = node.end;
|
||||
this._separatorStartIndex = 0;
|
||||
this._separatorEndIndex = 0;
|
||||
this.children = [];
|
||||
this.modification = new SpanModification(this);
|
||||
|
||||
let previousChildSpan: Span | undefined;
|
||||
|
||||
for (const childNode of this.node.getChildren() || []) {
|
||||
const childSpan: Span = new Span(childNode);
|
||||
// @ts-expect-error assigning private readonly properties on creation only
|
||||
childSpan._parent = this;
|
||||
// @ts-expect-error assigning private readonly properties on creation only
|
||||
childSpan._previousSibling = previousChildSpan;
|
||||
|
||||
if (previousChildSpan) {
|
||||
// @ts-expect-error assigning private readonly properties on creation only
|
||||
previousChildSpan._nextSibling = childSpan;
|
||||
}
|
||||
|
||||
this.children.push(childSpan);
|
||||
|
||||
// Normalize the bounds so that a child is never outside its parent
|
||||
if (childSpan.startIndex < this.startIndex) {
|
||||
this.startIndex = childSpan.startIndex;
|
||||
}
|
||||
|
||||
if (childSpan.endIndex > this.endIndex) {
|
||||
// This has never been observed empirically, but here's how we would handle it
|
||||
this.endIndex = childSpan.endIndex;
|
||||
throw new InternalError('Unexpected AST case');
|
||||
}
|
||||
|
||||
if (previousChildSpan && previousChildSpan.endIndex < childSpan.startIndex) {
|
||||
// There is some leftover text after previous child -- assign it as the separator for
|
||||
// the preceding span. If the preceding span has no suffix, then assign it to the
|
||||
// deepest preceding span with no suffix. This heuristic simplifies the most
|
||||
// common transformations, and otherwise it can be fished out using getLastInnerSeparator().
|
||||
let separatorRecipient: Span = previousChildSpan;
|
||||
while (separatorRecipient.children.length > 0) {
|
||||
const lastChild: Span = separatorRecipient.children[separatorRecipient.children.length - 1]!;
|
||||
if (lastChild.endIndex !== separatorRecipient.endIndex) {
|
||||
// There is a suffix, so we cannot push the separator any further down, or else
|
||||
// it would get printed before this suffix.
|
||||
break;
|
||||
}
|
||||
|
||||
separatorRecipient = lastChild;
|
||||
}
|
||||
|
||||
// @ts-expect-error assigning private readonly properties on creation only
|
||||
separatorRecipient._separatorStartIndex = previousChildSpan.endIndex;
|
||||
// @ts-expect-error assigning private readonly properties on creation only
|
||||
separatorRecipient._separatorEndIndex = childSpan.startIndex;
|
||||
}
|
||||
|
||||
previousChildSpan = childSpan;
|
||||
}
|
||||
}
|
||||
|
||||
public get kind(): ts.SyntaxKind {
|
||||
return this.node.kind;
|
||||
}
|
||||
|
||||
/**
|
||||
* The parent Span, if any.
|
||||
* NOTE: This will be undefined for a root Span, even though the corresponding Node
|
||||
* may have a parent in the AST.
|
||||
*/
|
||||
public get parent(): Span | undefined {
|
||||
return this._parent;
|
||||
}
|
||||
|
||||
/**
|
||||
* If the current object is this.parent.children[i], then previousSibling corresponds
|
||||
* to this.parent.children[i-1] if it exists.
|
||||
* NOTE: This will be undefined for a root Span, even though the corresponding Node
|
||||
* may have a previous sibling in the AST.
|
||||
*/
|
||||
public get previousSibling(): Span | undefined {
|
||||
return this._previousSibling;
|
||||
}
|
||||
|
||||
/**
|
||||
* If the current object is this.parent.children[i], then previousSibling corresponds
|
||||
* to this.parent.children[i+1] if it exists.
|
||||
* NOTE: This will be undefined for a root Span, even though the corresponding Node
|
||||
* may have a previous sibling in the AST.
|
||||
*/
|
||||
public get nextSibling(): Span | undefined {
|
||||
return this._nextSibling;
|
||||
}
|
||||
|
||||
/**
|
||||
* The text associated with the underlying Node, up to its first child.
|
||||
*/
|
||||
public get prefix(): string {
|
||||
if (this.children.length) {
|
||||
// Everything up to the first child
|
||||
return this._getSubstring(this.startIndex, this.children[0]!.startIndex);
|
||||
} else {
|
||||
return this._getSubstring(this.startIndex, this.endIndex);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The text associated with the underlying Node, after its last child.
|
||||
* If there are no children, this is always an empty string.
|
||||
*/
|
||||
public get suffix(): string {
|
||||
if (this.children.length) {
|
||||
// Everything after the last child
|
||||
return this._getSubstring(this.children[this.children.length - 1]!.endIndex, this.endIndex);
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whitespace that appeared after this node, and before the "next" node in the tree.
|
||||
* Here we mean "next" according to an inorder traversal, not necessarily a sibling.
|
||||
*/
|
||||
public get separator(): string {
|
||||
return this._getSubstring(this._separatorStartIndex, this._separatorEndIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the separator of this Span, or else recursively calls getLastInnerSeparator()
|
||||
* on the last child.
|
||||
*/
|
||||
public getLastInnerSeparator(): string {
|
||||
if (this.separator) {
|
||||
return this.separator;
|
||||
}
|
||||
|
||||
if (this.children.length > 0) {
|
||||
return this.children[this.children.length - 1]!.getLastInnerSeparator();
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the first parent node with the specified SyntaxKind, or undefined if there is no match.
|
||||
*/
|
||||
public findFirstParent(kindToMatch: ts.SyntaxKind): Span | undefined {
|
||||
let current: Span | undefined = this;
|
||||
|
||||
while (current) {
|
||||
if (current.kind === kindToMatch) {
|
||||
return current;
|
||||
}
|
||||
|
||||
current = current.parent;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively invokes the callback on this Span and all its children. The callback
|
||||
* can make changes to Span.modification for each node.
|
||||
*/
|
||||
public forEach(callback: (span: Span) => void): void {
|
||||
// eslint-disable-next-line n/callback-return, n/no-callback-literal
|
||||
callback(this);
|
||||
for (const child of this.children) {
|
||||
// eslint-disable-next-line unicorn/no-array-for-each
|
||||
child.forEach(callback);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the original unmodified text represented by this Span.
|
||||
*/
|
||||
public getText(): string {
|
||||
let result = '';
|
||||
result += this.prefix;
|
||||
|
||||
for (const child of this.children) {
|
||||
result += child.getText();
|
||||
}
|
||||
|
||||
result += this.suffix;
|
||||
result += this.separator;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the text represented by this Span, after applying all requested modifications.
|
||||
*/
|
||||
public getModifiedText(): string {
|
||||
const writer: IndentedWriter = new IndentedWriter();
|
||||
writer.trimLeadingSpaces = true;
|
||||
|
||||
this._writeModifiedText({
|
||||
writer,
|
||||
separatorOverride: undefined,
|
||||
indentDocCommentState: IndentDocCommentState.Inactive,
|
||||
});
|
||||
|
||||
return writer.getText();
|
||||
}
|
||||
|
||||
public writeModifiedText(output: IndentedWriter): void {
|
||||
this._writeModifiedText({
|
||||
writer: output,
|
||||
separatorOverride: undefined,
|
||||
indentDocCommentState: IndentDocCommentState.Inactive,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a diagnostic dump of the tree, showing the prefix/suffix/separator for
|
||||
* each node.
|
||||
*/
|
||||
public getDump(indent: string = ''): string {
|
||||
let result: string = indent + ts.SyntaxKind[this.node.kind] + ': ';
|
||||
|
||||
if (this.prefix) {
|
||||
result += ' pre=[' + this._getTrimmed(this.prefix) + ']';
|
||||
}
|
||||
|
||||
if (this.suffix) {
|
||||
result += ' suf=[' + this._getTrimmed(this.suffix) + ']';
|
||||
}
|
||||
|
||||
if (this.separator) {
|
||||
result += ' sep=[' + this._getTrimmed(this.separator) + ']';
|
||||
}
|
||||
|
||||
result += '\n';
|
||||
|
||||
for (const child of this.children) {
|
||||
result += child.getDump(indent + ' ');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a diagnostic dump of the tree, showing the SpanModification settings for each nodde.
|
||||
*/
|
||||
public getModifiedDump(indent: string = ''): string {
|
||||
let result: string = indent + ts.SyntaxKind[this.node.kind] + ': ';
|
||||
|
||||
if (this.prefix) {
|
||||
result += ' pre=[' + this._getTrimmed(this.modification.prefix) + ']';
|
||||
}
|
||||
|
||||
if (this.suffix) {
|
||||
result += ' suf=[' + this._getTrimmed(this.modification.suffix) + ']';
|
||||
}
|
||||
|
||||
if (this.separator) {
|
||||
result += ' sep=[' + this._getTrimmed(this.separator) + ']';
|
||||
}
|
||||
|
||||
if (this.modification.indentDocComment !== IndentDocCommentScope.None) {
|
||||
result += ' indentDocComment=' + IndentDocCommentScope[this.modification.indentDocComment];
|
||||
}
|
||||
|
||||
if (this.modification.omitChildren) {
|
||||
result += ' omitChildren';
|
||||
}
|
||||
|
||||
if (this.modification.omitSeparatorAfter) {
|
||||
result += ' omitSeparatorAfter';
|
||||
}
|
||||
|
||||
if (this.modification.sortChildren) {
|
||||
result += ' sortChildren';
|
||||
}
|
||||
|
||||
if (this.modification.sortKey !== undefined) {
|
||||
result += ` sortKey="${this.modification.sortKey}"`;
|
||||
}
|
||||
|
||||
result += '\n';
|
||||
|
||||
if (this.modification.omitChildren) {
|
||||
result += `${indent} (${this.children.length} children)\n`;
|
||||
} else {
|
||||
for (const child of this.children) {
|
||||
result += child.getModifiedDump(indent + ' ');
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursive implementation of `getModifiedText()` and `writeModifiedText()`.
|
||||
*/
|
||||
private _writeModifiedText(options: IWriteModifiedTextOptions): void {
|
||||
// Apply indentation based on "{" and "}"
|
||||
if (this.prefix === '{') {
|
||||
options.writer.increaseIndent();
|
||||
} else if (this.prefix === '}') {
|
||||
options.writer.decreaseIndent();
|
||||
}
|
||||
|
||||
if (this.modification.indentDocComment !== IndentDocCommentScope.None) {
|
||||
this._beginIndentDocComment(options);
|
||||
}
|
||||
|
||||
this._write(this.modification.prefix, options);
|
||||
|
||||
if (this.modification.indentDocComment === IndentDocCommentScope.PrefixOnly) {
|
||||
this._endIndentDocComment(options);
|
||||
}
|
||||
|
||||
let sortedSubset: Span[] | undefined;
|
||||
|
||||
if (!this.modification.omitChildren && this.modification.sortChildren) {
|
||||
// We will only sort the items with a sortKey
|
||||
const filtered: Span[] = this.children.filter((x) => x.modification.sortKey !== undefined);
|
||||
|
||||
// Is there at least one of them?
|
||||
if (filtered.length > 1) {
|
||||
sortedSubset = filtered;
|
||||
}
|
||||
}
|
||||
|
||||
if (sortedSubset) {
|
||||
// This is the complicated special case that sorts an arbitrary subset of the child nodes,
|
||||
// preserving the surrounding nodes.
|
||||
|
||||
const sortedSubsetCount: number = sortedSubset.length;
|
||||
// Remember the separator for the first and last ones
|
||||
const firstSeparator: string = sortedSubset[0]!.getLastInnerSeparator();
|
||||
const lastSeparator: string = sortedSubset[sortedSubsetCount - 1]!.getLastInnerSeparator();
|
||||
|
||||
Sort.sortBy(sortedSubset, (x) => x.modification.sortKey);
|
||||
|
||||
const childOptions: IWriteModifiedTextOptions = { ...options };
|
||||
|
||||
let sortedSubsetIndex = 0;
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-for-of
|
||||
for (let index = 0; index < this.children.length; ++index) {
|
||||
let current: Span;
|
||||
|
||||
// Is this an item that we sorted?
|
||||
if (this.children[index]!.modification.sortKey === undefined) {
|
||||
// No, take the next item from the original array
|
||||
current = this.children[index]!;
|
||||
childOptions.separatorOverride = undefined;
|
||||
} else {
|
||||
// Yes, take the next item from the sortedSubset
|
||||
current = sortedSubset[sortedSubsetIndex++]!;
|
||||
|
||||
if (sortedSubsetIndex < sortedSubsetCount) {
|
||||
childOptions.separatorOverride = firstSeparator;
|
||||
} else {
|
||||
childOptions.separatorOverride = lastSeparator;
|
||||
}
|
||||
}
|
||||
|
||||
current._writeModifiedText(childOptions);
|
||||
}
|
||||
} else {
|
||||
// This is the normal case that does not need to sort children
|
||||
const childrenLength: number = this.children.length;
|
||||
|
||||
if (!this.modification.omitChildren) {
|
||||
if (options.separatorOverride === undefined) {
|
||||
// The normal simple case
|
||||
for (const child of this.children) {
|
||||
child._writeModifiedText(options);
|
||||
}
|
||||
} else {
|
||||
// Special case where the separatorOverride is passed down to the "last inner separator" span
|
||||
for (let index = 0; index < childrenLength; ++index) {
|
||||
const child: Span = this.children[index]!;
|
||||
|
||||
if (
|
||||
// Only the last child inherits the separatorOverride, because only it can contain
|
||||
// the "last inner separator" span
|
||||
index < childrenLength - 1 ||
|
||||
// If this.separator is specified, then we will write separatorOverride below, so don't pass it along
|
||||
this.separator
|
||||
) {
|
||||
const childOptions: IWriteModifiedTextOptions = { ...options };
|
||||
childOptions.separatorOverride = undefined;
|
||||
child._writeModifiedText(childOptions);
|
||||
} else {
|
||||
child._writeModifiedText(options);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this._write(this.modification.suffix, options);
|
||||
|
||||
if (options.separatorOverride !== undefined) {
|
||||
if (this.separator || childrenLength === 0) {
|
||||
this._write(options.separatorOverride, options);
|
||||
}
|
||||
} else if (!this.modification.omitSeparatorAfter) {
|
||||
this._write(this.separator, options);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.modification.indentDocComment === IndentDocCommentScope.SpanAndChildren) {
|
||||
this._endIndentDocComment(options);
|
||||
}
|
||||
}
|
||||
|
||||
private _beginIndentDocComment(options: IWriteModifiedTextOptions): void {
|
||||
if (options.indentDocCommentState !== IndentDocCommentState.Inactive) {
|
||||
throw new InternalError('indentDocComment cannot be nested');
|
||||
}
|
||||
|
||||
options.indentDocCommentState = IndentDocCommentState.AwaitingOpenDelimiter;
|
||||
}
|
||||
|
||||
private _endIndentDocComment(options: IWriteModifiedTextOptions): void {
|
||||
if (options.indentDocCommentState === IndentDocCommentState.AwaitingCloseDelimiter) {
|
||||
throw new InternalError('missing "*/" delimiter for comment block');
|
||||
}
|
||||
|
||||
options.indentDocCommentState = IndentDocCommentState.Inactive;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes one chunk of `text` to the `options.writer`, applying the `indentDocComment` rewriting.
|
||||
*/
|
||||
private _write(text: string, options: IWriteModifiedTextOptions): void {
|
||||
let parsedText: string = text;
|
||||
|
||||
if (options.indentDocCommentState === IndentDocCommentState.AwaitingOpenDelimiter) {
|
||||
let index: number = parsedText.indexOf('/*');
|
||||
if (index >= 0) {
|
||||
index += '/*'.length;
|
||||
options.writer.write(parsedText.slice(0, Math.max(0, index)));
|
||||
parsedText = parsedText.slice(Math.max(0, index));
|
||||
options.indentDocCommentState = IndentDocCommentState.AwaitingCloseDelimiter;
|
||||
|
||||
options.writer.increaseIndent(' ');
|
||||
}
|
||||
}
|
||||
|
||||
if (options.indentDocCommentState === IndentDocCommentState.AwaitingCloseDelimiter) {
|
||||
let index: number = parsedText.indexOf('*/');
|
||||
if (index >= 0) {
|
||||
index += '*/'.length;
|
||||
options.writer.write(parsedText.slice(0, Math.max(0, index)));
|
||||
parsedText = parsedText.slice(Math.max(0, index));
|
||||
options.indentDocCommentState = IndentDocCommentState.Done;
|
||||
|
||||
options.writer.decreaseIndent();
|
||||
}
|
||||
}
|
||||
|
||||
options.writer.write(parsedText);
|
||||
}
|
||||
|
||||
private _getTrimmed(text: string): string {
|
||||
const trimmed: string = text.replaceAll(/\r?\n/g, '\\n');
|
||||
|
||||
if (trimmed.length > 100) {
|
||||
return trimmed.slice(0, 97) + '...';
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private _getSubstring(startIndex: number, endIndex: number): string {
|
||||
if (startIndex === endIndex) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return this.node.getSourceFile().text.slice(startIndex, endIndex);
|
||||
}
|
||||
}
|
||||
80
packages/api-extractor/src/analyzer/SyntaxHelpers.ts
Normal file
80
packages/api-extractor/src/analyzer/SyntaxHelpers.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
import * as ts from 'typescript';
|
||||
|
||||
/**
|
||||
* Helpers for validating various text string formats.
|
||||
*/
|
||||
export class SyntaxHelpers {
|
||||
/**
|
||||
* Tests whether the input string is safe to use as an ECMAScript identifier without quotes.
|
||||
*
|
||||
* @remarks
|
||||
* For example:
|
||||
*
|
||||
* ```ts
|
||||
* class X {
|
||||
* public okay: number = 1;
|
||||
* public "not okay!": number = 2;
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* A precise check is extremely complicated and highly dependent on the ECMAScript standard version
|
||||
* and how faithfully the interpreter implements it. To keep things simple, `isSafeUnquotedMemberIdentifier()`
|
||||
* conservatively accepts any identifier that would be valid with ECMAScript 5, and returns false otherwise.
|
||||
*/
|
||||
public static isSafeUnquotedMemberIdentifier(identifier: string): boolean {
|
||||
if (identifier.length === 0) {
|
||||
return false; // cannot be empty
|
||||
}
|
||||
|
||||
if (!ts.isIdentifierStart(identifier.codePointAt(0)!, ts.ScriptTarget.ES5)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let index = 1; index < identifier.length; index++) {
|
||||
if (!ts.isIdentifierPart(identifier.codePointAt(index)!, ts.ScriptTarget.ES5)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an arbitrary input string, return a regular TypeScript identifier name.
|
||||
*
|
||||
* @remarks
|
||||
* Example input: "api-extractor-lib1-test"
|
||||
* Example output: "apiExtractorLib1Test"
|
||||
*/
|
||||
public static makeCamelCaseIdentifier(input: string): string {
|
||||
const parts: string[] = input.split(/\W+/).filter((x) => x.length > 0);
|
||||
if (parts.length === 0) {
|
||||
return '_';
|
||||
}
|
||||
|
||||
for (let index = 0; index < parts.length; ++index) {
|
||||
let part: string = parts[index]!;
|
||||
if (part.toUpperCase() === part) {
|
||||
// Preserve existing case unless the part is all upper-case
|
||||
part = part.toLowerCase();
|
||||
}
|
||||
|
||||
if (index === 0) {
|
||||
// If the first part starts with a number, prepend "_"
|
||||
if (/\d/.test(part.charAt(0))) {
|
||||
part = '_' + part;
|
||||
}
|
||||
} else {
|
||||
// Capitalize the first letter of each part, except for the first one
|
||||
part = part.charAt(0).toUpperCase() + part.slice(1);
|
||||
}
|
||||
|
||||
parts[index] = part;
|
||||
}
|
||||
|
||||
return parts.join('');
|
||||
}
|
||||
}
|
||||
292
packages/api-extractor/src/analyzer/TypeScriptHelpers.ts
Normal file
292
packages/api-extractor/src/analyzer/TypeScriptHelpers.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
import { InternalError } from '@rushstack/node-core-library';
|
||||
import * as ts from 'typescript';
|
||||
import { SourceFileLocationFormatter } from './SourceFileLocationFormatter.js';
|
||||
import { TypeScriptInternals } from './TypeScriptInternals.js';
|
||||
|
||||
export class TypeScriptHelpers {
|
||||
// Matches TypeScript's encoded names for well-known ECMAScript symbols like
|
||||
// "__@iterator" or "__@toStringTag".
|
||||
private static readonly _wellKnownSymbolNameRegExp: RegExp = /^__@(?<identifier>\w+)$/;
|
||||
|
||||
// Matches TypeScript's encoded names for late-bound symbols derived from `unique symbol` declarations
|
||||
// which have the form of "__@<variableName>@<symbolId>", i.e. "__@someSymbol@12345".
|
||||
private static readonly _uniqueSymbolNameRegExp: RegExp = /^__@.*@\d+$/;
|
||||
|
||||
/**
|
||||
* This traverses any symbol aliases to find the original place where an item was defined.
|
||||
* For example, suppose a class is defined as "export default class MyClass \{ \}"
|
||||
* but exported from the package's index.ts like this:
|
||||
*
|
||||
* export \{ default as _MyClass \} from './MyClass';
|
||||
*
|
||||
* In this example, calling followAliases() on the _MyClass symbol will return the
|
||||
* original definition of MyClass, traversing any intermediary places where the
|
||||
* symbol was imported and re-exported.
|
||||
*/
|
||||
public static followAliases(symbol: ts.Symbol, typeChecker: ts.TypeChecker): ts.Symbol {
|
||||
let current: ts.Symbol = symbol;
|
||||
for (;;) {
|
||||
if (!(current.flags & ts.SymbolFlags.Alias)) {
|
||||
break;
|
||||
}
|
||||
|
||||
const currentAlias: ts.Symbol = typeChecker.getAliasedSymbol(current);
|
||||
if (!currentAlias || currentAlias === current) {
|
||||
break;
|
||||
}
|
||||
|
||||
current = currentAlias;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if TypeScriptHelpers.followAliases() would return something different
|
||||
* from the input `symbol`.
|
||||
*/
|
||||
public static isFollowableAlias(symbol: ts.Symbol, typeChecker: ts.TypeChecker): boolean {
|
||||
if (!(symbol.flags & ts.SymbolFlags.Alias)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const alias: ts.Symbol = typeChecker.getAliasedSymbol(symbol);
|
||||
|
||||
return alias && alias !== symbol;
|
||||
}
|
||||
|
||||
/**
|
||||
* Certain virtual symbols do not have any declarations. For example, `ts.TypeChecker.getExportsOfModule()` can
|
||||
* sometimes return a "prototype" symbol for an object, even though there is no corresponding declaration in the
|
||||
* source code. API Extractor generally ignores such symbols.
|
||||
*/
|
||||
public static tryGetADeclaration(symbol: ts.Symbol): ts.Declaration | undefined {
|
||||
if (symbol.declarations && symbol.declarations.length > 0) {
|
||||
return symbol.declarations[0];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the specified symbol is an ambient declaration.
|
||||
*/
|
||||
public static isAmbient(symbol: ts.Symbol, typeChecker: ts.TypeChecker): boolean {
|
||||
const followedSymbol: ts.Symbol = TypeScriptHelpers.followAliases(symbol, typeChecker);
|
||||
|
||||
if (followedSymbol.declarations && followedSymbol.declarations.length > 0) {
|
||||
const firstDeclaration: ts.Declaration = followedSymbol.declarations[0]!;
|
||||
|
||||
// Test 1: Are we inside the sinister "declare global {" construct?
|
||||
const highestModuleDeclaration: ts.ModuleDeclaration | undefined = TypeScriptHelpers.findHighestParent(
|
||||
firstDeclaration,
|
||||
ts.SyntaxKind.ModuleDeclaration,
|
||||
);
|
||||
if (highestModuleDeclaration && highestModuleDeclaration.name.getText().trim() === 'global') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Test 2: Otherwise, the main heuristic for ambient declarations is by looking at the
|
||||
// ts.SyntaxKind.SourceFile node to see whether it has a symbol or not (i.e. whether it
|
||||
// is acting as a module or not).
|
||||
const sourceFile: ts.SourceFile = firstDeclaration.getSourceFile();
|
||||
|
||||
if (typeChecker.getSymbolAtLocation(sourceFile)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same semantics as tryGetSymbolForDeclaration(), but throws an exception if the symbol
|
||||
* cannot be found.
|
||||
*/
|
||||
public static getSymbolForDeclaration(declaration: ts.Declaration, checker: ts.TypeChecker): ts.Symbol {
|
||||
const symbol: ts.Symbol | undefined = TypeScriptInternals.tryGetSymbolForDeclaration(declaration, checker);
|
||||
if (!symbol) {
|
||||
throw new InternalError(
|
||||
'Unable to determine semantic information for declaration:\n' +
|
||||
SourceFileLocationFormatter.formatDeclaration(declaration),
|
||||
);
|
||||
}
|
||||
|
||||
return symbol;
|
||||
}
|
||||
|
||||
// Return name of the module, which could be like "./SomeLocalFile' or like 'external-package/entry/point'
|
||||
public static getModuleSpecifier(
|
||||
nodeWithModuleSpecifier: ts.ExportDeclaration | ts.ImportDeclaration | ts.ImportTypeNode,
|
||||
): string | undefined {
|
||||
if (nodeWithModuleSpecifier.kind === ts.SyntaxKind.ImportType) {
|
||||
// As specified internally in typescript:/src/compiler/types.ts#ValidImportTypeNode
|
||||
if (
|
||||
nodeWithModuleSpecifier.argument.kind !== ts.SyntaxKind.LiteralType ||
|
||||
(nodeWithModuleSpecifier.argument as ts.LiteralTypeNode).literal.kind !== ts.SyntaxKind.StringLiteral
|
||||
) {
|
||||
throw new InternalError(
|
||||
`Invalid ImportTypeNode: ${nodeWithModuleSpecifier.getText()}\n` +
|
||||
SourceFileLocationFormatter.formatDeclaration(nodeWithModuleSpecifier),
|
||||
);
|
||||
}
|
||||
|
||||
const literalTypeNode: ts.LiteralTypeNode = nodeWithModuleSpecifier.argument as ts.LiteralTypeNode;
|
||||
const stringLiteral: ts.StringLiteral = literalTypeNode.literal as ts.StringLiteral;
|
||||
return stringLiteral.text.trim();
|
||||
}
|
||||
|
||||
// Node is a declaration
|
||||
if (nodeWithModuleSpecifier.moduleSpecifier && ts.isStringLiteralLike(nodeWithModuleSpecifier.moduleSpecifier)) {
|
||||
return TypeScriptInternals.getTextOfIdentifierOrLiteral(nodeWithModuleSpecifier.moduleSpecifier);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an ancestor of "node", such that the ancestor, any intermediary nodes,
|
||||
* and the starting node match a list of expected kinds. Undefined is returned
|
||||
* if there aren't enough ancestors, or if the kinds are incorrect.
|
||||
*
|
||||
* For example, suppose child "C" has parents A --\> B --\> C.
|
||||
*
|
||||
* Calling _matchAncestor(C, [ExportSpecifier, NamedExports, ExportDeclaration])
|
||||
* would return A only if A is of kind ExportSpecifier, B is of kind NamedExports,
|
||||
* and C is of kind ExportDeclaration.
|
||||
*
|
||||
* Calling _matchAncestor(C, [ExportDeclaration]) would return C.
|
||||
*/
|
||||
public static matchAncestor<T extends ts.Node>(node: ts.Node, kindsToMatch: ts.SyntaxKind[]): T | undefined {
|
||||
// (slice(0) clones an array)
|
||||
const reversedParentKinds: ts.SyntaxKind[] = kindsToMatch.slice(0).reverse();
|
||||
|
||||
let current: ts.Node | undefined;
|
||||
|
||||
for (const parentKind of reversedParentKinds) {
|
||||
if (current) {
|
||||
// Then walk the parents
|
||||
current = current.parent;
|
||||
} else {
|
||||
// The first time through, start with node
|
||||
current = node;
|
||||
}
|
||||
|
||||
// If we ran out of items, or if the kind doesn't match, then fail
|
||||
if (!current || current.kind !== parentKind) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// If we matched everything, then return the node that matched the last parentKinds item
|
||||
return current as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does a depth-first search of the children of the specified node. Returns the first child
|
||||
* with the specified kind, or undefined if there is no match.
|
||||
*/
|
||||
public static findFirstChildNode<T extends ts.Node>(node: ts.Node, kindToMatch: ts.SyntaxKind): T | undefined {
|
||||
for (const child of node.getChildren()) {
|
||||
if (child.kind === kindToMatch) {
|
||||
return child as T;
|
||||
}
|
||||
|
||||
const recursiveMatch: T | undefined = TypeScriptHelpers.findFirstChildNode(child, kindToMatch);
|
||||
if (recursiveMatch) {
|
||||
return recursiveMatch;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the first parent node with the specified SyntaxKind, or undefined if there is no match.
|
||||
*/
|
||||
public static findFirstParent<T extends ts.Node>(node: ts.Node, kindToMatch: ts.SyntaxKind): T | undefined {
|
||||
let current: ts.Node | undefined = node.parent;
|
||||
|
||||
while (current) {
|
||||
if (current.kind === kindToMatch) {
|
||||
return current as T;
|
||||
}
|
||||
|
||||
current = current.parent;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the highest parent node with the specified SyntaxKind, or undefined if there is no match.
|
||||
*
|
||||
* @remarks
|
||||
* Whereas findFirstParent() returns the first match, findHighestParent() returns the last match.
|
||||
*/
|
||||
public static findHighestParent<T extends ts.Node>(node: ts.Node, kindToMatch: ts.SyntaxKind): T | undefined {
|
||||
let current: ts.Node | undefined = node;
|
||||
let highest: T | undefined;
|
||||
|
||||
for (;;) {
|
||||
current = TypeScriptHelpers.findFirstParent<T>(current, kindToMatch);
|
||||
if (!current) {
|
||||
break;
|
||||
}
|
||||
|
||||
highest = current as T;
|
||||
}
|
||||
|
||||
return highest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes the names that the compiler generates for a built-in ECMAScript symbol.
|
||||
*
|
||||
* @remarks
|
||||
* TypeScript binds well-known ECMAScript symbols like `[Symbol.iterator]` as `__@iterator`.
|
||||
* If `name` is of this form, then `tryGetWellKnownSymbolName()` converts it back into e.g. `[Symbol.iterator]`.
|
||||
* If the string does not start with `__@` then `undefined` is returned.
|
||||
*/
|
||||
public static tryDecodeWellKnownSymbolName(name: ts.__String): string | undefined {
|
||||
const match = TypeScriptHelpers._wellKnownSymbolNameRegExp.exec(name as string);
|
||||
if (match?.groups?.identifier) {
|
||||
const identifier: string = match.groups.identifier;
|
||||
return `[Symbol.${identifier}]`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the provided name was generated for a TypeScript `unique symbol`.
|
||||
*/
|
||||
public static isUniqueSymbolName(name: ts.__String): boolean {
|
||||
return TypeScriptHelpers._uniqueSymbolNameRegExp.test(name as string);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the string representation of a TypeScript late-bound symbol.
|
||||
*/
|
||||
public static tryGetLateBoundName(declarationName: ts.ComputedPropertyName): string | undefined {
|
||||
// Create a node printer that ignores comments and indentation that we can use to convert
|
||||
// declarationName to a string.
|
||||
const printer: ts.Printer = ts.createPrinter(
|
||||
{ removeComments: true },
|
||||
{
|
||||
onEmitNode(hint: ts.EmitHint, node: ts.Node, emitCallback: (hint: ts.EmitHint, node: ts.Node) => void): void {
|
||||
ts.setEmitFlags(declarationName, ts.EmitFlags.NoIndentation | ts.EmitFlags.SingleLine);
|
||||
emitCallback(hint, node);
|
||||
},
|
||||
},
|
||||
);
|
||||
const sourceFile: ts.SourceFile = declarationName.getSourceFile();
|
||||
const text: string = printer.printNode(ts.EmitHint.Unspecified, declarationName, sourceFile);
|
||||
// clean up any emit flags we've set on any nodes in the tree.
|
||||
ts.disposeEmitNodes(sourceFile);
|
||||
return text;
|
||||
}
|
||||
}
|
||||
144
packages/api-extractor/src/analyzer/TypeScriptInternals.ts
Normal file
144
packages/api-extractor/src/analyzer/TypeScriptInternals.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
import { InternalError } from '@rushstack/node-core-library';
|
||||
import * as ts from 'typescript';
|
||||
|
||||
/**
|
||||
* Exposes the TypeScript compiler internals for detecting global variable names.
|
||||
*/
|
||||
export interface IGlobalVariableAnalyzer {
|
||||
hasGlobalName(name: string): boolean;
|
||||
}
|
||||
|
||||
export class TypeScriptInternals {
|
||||
public static getImmediateAliasedSymbol(symbol: ts.Symbol, typeChecker: ts.TypeChecker): ts.Symbol {
|
||||
// Compiler internal:
|
||||
// https://github.com/microsoft/TypeScript/blob/v3.2.2/src/compiler/checker.ts
|
||||
return (typeChecker as any).getImmediateAliasedSymbol(symbol);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Symbol for the provided Declaration. This is a workaround for a missing
|
||||
* feature of the TypeScript Compiler API. It is the only apparent way to reach
|
||||
* certain data structures, and seems to always work, but is not officially documented.
|
||||
*
|
||||
* @returns The associated Symbol. If there is no semantic information (e.g. if the
|
||||
* declaration is an extra semicolon somewhere), then "undefined" is returned.
|
||||
*/
|
||||
public static tryGetSymbolForDeclaration(
|
||||
declaration: ts.Declaration,
|
||||
checker: ts.TypeChecker,
|
||||
): ts.Symbol | undefined {
|
||||
let symbol: ts.Symbol | undefined = (declaration as any).symbol;
|
||||
if (symbol && symbol.escapedName === ts.InternalSymbolName.Computed) {
|
||||
const name: ts.DeclarationName | undefined = ts.getNameOfDeclaration(declaration);
|
||||
symbol = (name && checker.getSymbolAtLocation(name)) || symbol;
|
||||
}
|
||||
|
||||
return symbol;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the provided Symbol is a TypeScript "late-bound" Symbol (i.e. was created by the Checker
|
||||
* for a computed property based on its type, rather than by the Binder).
|
||||
*/
|
||||
public static isLateBoundSymbol(symbol: ts.Symbol): boolean {
|
||||
return (
|
||||
(symbol.flags & ts.SymbolFlags.Transient) !== 0 &&
|
||||
(ts as any).getCheckFlags(symbol) === (ts as any).CheckFlags.Late
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the comment ranges associated with the specified node.
|
||||
*/
|
||||
public static getJSDocCommentRanges(node: ts.Node, text: string): ts.CommentRange[] | undefined {
|
||||
// Compiler internal:
|
||||
// https://github.com/microsoft/TypeScript/blob/v2.4.2/src/compiler/utilities.ts#L616
|
||||
|
||||
return Reflect.apply((ts as any).getJSDocCommentRanges, this, [node, text]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the (unescaped) value of an string literal, numeric literal, or identifier.
|
||||
*/
|
||||
public static getTextOfIdentifierOrLiteral(node: ts.Identifier | ts.NumericLiteral | ts.StringLiteralLike): string {
|
||||
// Compiler internal:
|
||||
// https://github.com/microsoft/TypeScript/blob/v3.2.2/src/compiler/utilities.ts#L2721
|
||||
|
||||
return (ts as any).getTextOfIdentifierOrLiteral(node);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the (cached) module resolution information for a module name that was exported from a SourceFile.
|
||||
* The compiler populates this cache as part of analyzing the source file.
|
||||
*/
|
||||
public static getResolvedModule(
|
||||
sourceFile: ts.SourceFile,
|
||||
moduleNameText: string,
|
||||
mode: ts.ModuleKind.CommonJS | ts.ModuleKind.ESNext | undefined,
|
||||
): ts.ResolvedModuleFull | undefined {
|
||||
// Compiler internal:
|
||||
// https://github.com/microsoft/TypeScript/blob/v4.7.2/src/compiler/utilities.ts#L161
|
||||
|
||||
return (ts as any).getResolvedModule(sourceFile, moduleNameText, mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the mode required for module resolution required with the addition of Node16/nodenext
|
||||
*/
|
||||
public static getModeForUsageLocation(
|
||||
file: { impliedNodeFormat?: ts.SourceFile['impliedNodeFormat'] },
|
||||
usage: ts.StringLiteralLike | undefined,
|
||||
): ts.ModuleKind.CommonJS | ts.ModuleKind.ESNext | undefined {
|
||||
// Compiler internal:
|
||||
// https://github.com/microsoft/TypeScript/blob/v4.7.2/src/compiler/program.ts#L568
|
||||
|
||||
return (ts as any).getModeForUsageLocation?.(file, usage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns ts.Symbol.parent if it exists.
|
||||
*/
|
||||
public static getSymbolParent(symbol: ts.Symbol): ts.Symbol | undefined {
|
||||
return (symbol as any).parent;
|
||||
}
|
||||
|
||||
/**
|
||||
* In an statement like `export default class X { }`, the `Symbol.name` will be `default`
|
||||
* whereas the `localSymbol` is `X`.
|
||||
*/
|
||||
public static tryGetLocalSymbol(declaration: ts.Declaration): ts.Symbol | undefined {
|
||||
return (declaration as any).localSymbol;
|
||||
}
|
||||
|
||||
public static getGlobalVariableAnalyzer(program: ts.Program): IGlobalVariableAnalyzer {
|
||||
const anyProgram: any = program;
|
||||
const typeCheckerInstance: any = anyProgram.getDiagnosticsProducingTypeChecker ?? anyProgram.getTypeChecker;
|
||||
|
||||
if (!typeCheckerInstance) {
|
||||
throw new InternalError('Missing Program.getDiagnosticsProducingTypeChecker or Program.getTypeChecker');
|
||||
}
|
||||
|
||||
const typeChecker: any = typeCheckerInstance();
|
||||
if (!typeChecker.getEmitResolver) {
|
||||
throw new InternalError('Missing TypeChecker.getEmitResolver');
|
||||
}
|
||||
|
||||
const resolver: any = typeChecker.getEmitResolver();
|
||||
if (!resolver.hasGlobalName) {
|
||||
throw new InternalError('Missing EmitResolver.hasGlobalName');
|
||||
}
|
||||
|
||||
return resolver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a variable is declared with the const keyword
|
||||
*/
|
||||
public static isVarConst(node: ts.VariableDeclaration | ts.VariableDeclarationList): boolean {
|
||||
// Compiler internal: https://github.com/microsoft/TypeScript/blob/71286e3d49c10e0e99faac360a6bbd40f12db7b6/src/compiler/utilities.ts#L925
|
||||
return (ts as any).isVarConst(node);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
import * as path from 'node:path';
|
||||
import { FileSystem, PackageJsonLookup, type INodePackageJson, NewlineKind } from '@rushstack/node-core-library';
|
||||
import { PackageMetadataManager } from '../PackageMetadataManager.js';
|
||||
|
||||
const packageJsonLookup: PackageJsonLookup = new PackageJsonLookup();
|
||||
|
||||
function resolveInTestPackage(testPackageName: string, ...args: string[]): string {
|
||||
return path.resolve(__dirname, 'test-data/tsdoc-metadata-path-inference', testPackageName, ...args);
|
||||
}
|
||||
|
||||
function getPackageMetadata(testPackageName: string): {
|
||||
packageFolder: string;
|
||||
packageJson: INodePackageJson;
|
||||
} {
|
||||
const packageFolder: string = resolveInTestPackage(testPackageName);
|
||||
const packageJson: INodePackageJson | undefined = packageJsonLookup.tryLoadPackageJsonFor(packageFolder);
|
||||
if (!packageJson) {
|
||||
throw new Error('There should be a package.json file in the test package');
|
||||
}
|
||||
|
||||
return { packageFolder, packageJson };
|
||||
}
|
||||
|
||||
function firstArgument(mockFn: jest.Mock): any {
|
||||
return mockFn.mock.calls[0][0];
|
||||
}
|
||||
|
||||
describe(PackageMetadataManager.name, () => {
|
||||
describe(PackageMetadataManager.writeTsdocMetadataFile.name, () => {
|
||||
const originalWriteFile = FileSystem.writeFile;
|
||||
const mockWriteFile: jest.Mock = jest.fn();
|
||||
beforeAll(() => {
|
||||
FileSystem.writeFile = mockWriteFile;
|
||||
});
|
||||
afterEach(() => {
|
||||
mockWriteFile.mockClear();
|
||||
});
|
||||
afterAll(() => {
|
||||
FileSystem.writeFile = originalWriteFile;
|
||||
});
|
||||
|
||||
it('writes the tsdoc metadata file at the provided path', () => {
|
||||
PackageMetadataManager.writeTsdocMetadataFile('/foo/bar', NewlineKind.CrLf);
|
||||
expect(firstArgument(mockWriteFile)).toBe('/foo/bar');
|
||||
});
|
||||
});
|
||||
|
||||
describe(PackageMetadataManager.resolveTsdocMetadataPath.name, () => {
|
||||
describe('when an empty tsdocMetadataPath is provided', () => {
|
||||
const tsdocMetadataPath = '';
|
||||
describe('given a package.json where the field "tsdocMetadata" is defined', () => {
|
||||
it('outputs the tsdoc metadata path as given by "tsdocMetadata" relative to the folder of package.json', () => {
|
||||
const { packageFolder, packageJson } = getPackageMetadata('package-inferred-from-tsdoc-metadata');
|
||||
expect(PackageMetadataManager.resolveTsdocMetadataPath(packageFolder, packageJson, tsdocMetadataPath)).toBe(
|
||||
path.resolve(packageFolder, packageJson.tsdocMetadata as string),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('given a package.json where the field "typings" is defined and "tsdocMetadata" is not defined', () => {
|
||||
it('outputs the tsdoc metadata file "tsdoc-metadata.json" in the same folder as the path of "typings"', () => {
|
||||
const { packageFolder, packageJson } = getPackageMetadata('package-inferred-from-typings');
|
||||
expect(PackageMetadataManager.resolveTsdocMetadataPath(packageFolder, packageJson, tsdocMetadataPath)).toBe(
|
||||
path.resolve(packageFolder, path.dirname(packageJson.typings!), 'tsdoc-metadata.json'),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('given a package.json where the field "main" is defined but not "typings" nor "tsdocMetadata"', () => {
|
||||
it('outputs the tsdoc metadata file "tsdoc-metadata.json" in the same folder as the path of "main"', () => {
|
||||
const { packageFolder, packageJson } = getPackageMetadata('package-inferred-from-main');
|
||||
expect(PackageMetadataManager.resolveTsdocMetadataPath(packageFolder, packageJson, tsdocMetadataPath)).toBe(
|
||||
path.resolve(packageFolder, path.dirname(packageJson.main!), 'tsdoc-metadata.json'),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('given a package.json where the fields "main", "typings" and "tsdocMetadata" are not defined', () => {
|
||||
it('outputs the tsdoc metadata file "tsdoc-metadata.json" in the folder where package.json is located', () => {
|
||||
const { packageFolder, packageJson } = getPackageMetadata('package-default');
|
||||
expect(PackageMetadataManager.resolveTsdocMetadataPath(packageFolder, packageJson, tsdocMetadataPath)).toBe(
|
||||
path.resolve(packageFolder, 'tsdoc-metadata.json'),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('when a non-empty tsdocMetadataPath is provided', () => {
|
||||
const tsdocMetadataPath = 'path/to/custom-tsdoc-metadata.json';
|
||||
describe('given a package.json where the field "tsdocMetadata" is defined', () => {
|
||||
it('outputs the tsdoc metadata file at the provided path in the folder where package.json is located', () => {
|
||||
const { packageFolder, packageJson } = getPackageMetadata('package-inferred-from-tsdocMetadata');
|
||||
expect(PackageMetadataManager.resolveTsdocMetadataPath(packageFolder, packageJson, tsdocMetadataPath)).toBe(
|
||||
path.resolve(packageFolder, tsdocMetadataPath),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('given a package.json where the field "typings" is defined and "tsdocMetadata" is not defined', () => {
|
||||
it('outputs the tsdoc metadata file at the provided path in the folder where package.json is located', () => {
|
||||
const { packageFolder, packageJson } = getPackageMetadata('package-inferred-from-typings');
|
||||
expect(PackageMetadataManager.resolveTsdocMetadataPath(packageFolder, packageJson, tsdocMetadataPath)).toBe(
|
||||
path.resolve(packageFolder, tsdocMetadataPath),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('given a package.json where the field "main" is defined but not "typings" nor "tsdocMetadata"', () => {
|
||||
it('outputs the tsdoc metadata file at the provided path in the folder where package.json is located', () => {
|
||||
const { packageFolder, packageJson } = getPackageMetadata('package-inferred-from-main');
|
||||
expect(PackageMetadataManager.resolveTsdocMetadataPath(packageFolder, packageJson, tsdocMetadataPath)).toBe(
|
||||
path.resolve(packageFolder, tsdocMetadataPath),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('given a package.json where the fields "main", "typings" and "tsdocMetadata" are not defined', () => {
|
||||
it('outputs the tsdoc metadata file at the provided path in the folder where package.json is located', () => {
|
||||
const { packageFolder, packageJson } = getPackageMetadata('package-default');
|
||||
expect(PackageMetadataManager.resolveTsdocMetadataPath(packageFolder, packageJson, tsdocMetadataPath)).toBe(
|
||||
path.resolve(packageFolder, tsdocMetadataPath),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/* eslint-enable @typescript-eslint/typedef */
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
import { SyntaxHelpers } from '../SyntaxHelpers.js';
|
||||
|
||||
describe(SyntaxHelpers.name, () => {
|
||||
it(SyntaxHelpers.makeCamelCaseIdentifier.name, () => {
|
||||
// prettier-ignore
|
||||
const inputs:string[] = [
|
||||
'',
|
||||
'@#(&*^',
|
||||
'api-extractor-lib1-test',
|
||||
'one',
|
||||
'one-two',
|
||||
'ONE-TWO',
|
||||
'ONE/two/ /three/FOUR',
|
||||
'01234'
|
||||
];
|
||||
|
||||
expect(
|
||||
inputs.map((x) => {
|
||||
return { input: x, output: SyntaxHelpers.makeCamelCaseIdentifier(x) };
|
||||
}),
|
||||
).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"input": "",
|
||||
"output": "_",
|
||||
},
|
||||
Object {
|
||||
"input": "@#(&*^",
|
||||
"output": "_",
|
||||
},
|
||||
Object {
|
||||
"input": "api-extractor-lib1-test",
|
||||
"output": "apiExtractorLib1Test",
|
||||
},
|
||||
Object {
|
||||
"input": "one",
|
||||
"output": "one",
|
||||
},
|
||||
Object {
|
||||
"input": "one-two",
|
||||
"output": "oneTwo",
|
||||
},
|
||||
Object {
|
||||
"input": "ONE-TWO",
|
||||
"output": "oneTwo",
|
||||
},
|
||||
Object {
|
||||
"input": "ONE/two/ /three/FOUR",
|
||||
"output": "oneTwoThreeFour",
|
||||
},
|
||||
Object {
|
||||
"input": "01234",
|
||||
"output": "_01234",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "package-default",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "package-inferred-from-main",
|
||||
"version": "1.0.0",
|
||||
"main": "path/to/main.js"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "package-inferred-from-tsdoc-metadata",
|
||||
"version": "1.0.0",
|
||||
"main": "path/to/main.js",
|
||||
"typings": "path/to/typings.d.ts",
|
||||
"tsdocMetadata": "path/to/tsdoc-metadata.json"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "package-inferred-from-typings",
|
||||
"version": "1.0.0",
|
||||
"main": "path/to/main.js",
|
||||
"typings": "path/to/typings.d.ts"
|
||||
}
|
||||
Reference in New Issue
Block a user