mirror of
https://github.com/discordjs/discord.js.git
synced 2026-03-12 01:23:31 +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:
70
packages/api-extractor/src/aedoc/PackageDocComment.ts
Normal file
70
packages/api-extractor/src/aedoc/PackageDocComment.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
// 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';
|
||||
import { ExtractorMessageId } from '../api/ExtractorMessageId.js';
|
||||
import type { Collector } from '../collector/Collector.js';
|
||||
|
||||
export class PackageDocComment {
|
||||
/**
|
||||
* For the given source file, see if it starts with a TSDoc comment containing the `@packageDocumentation` tag.
|
||||
*/
|
||||
public static tryFindInSourceFile(sourceFile: ts.SourceFile, collector: Collector): ts.TextRange | undefined {
|
||||
// The @packageDocumentation comment is special because it is not attached to an AST
|
||||
// definition. Instead, it is part of the "trivia" tokens that the compiler treats
|
||||
// as irrelevant white space.
|
||||
//
|
||||
// WARNING: If the comment doesn't precede an export statement, the compiler will omit
|
||||
// it from the *.d.ts file, and API Extractor won't find it. If this happens, you need
|
||||
// to rearrange your statements to ensure it is passed through.
|
||||
//
|
||||
// This implementation assumes that the "@packageDocumentation" will be in the first TSDoc comment
|
||||
// that appears in the entry point *.d.ts file. We could possibly look in other places,
|
||||
// but the above warning suggests enforcing a standardized layout. This design choice is open
|
||||
// to feedback.
|
||||
let packageCommentRange: ts.TextRange | undefined; // empty string
|
||||
|
||||
for (const commentRange of ts.getLeadingCommentRanges(sourceFile.text, sourceFile.getFullStart()) ?? []) {
|
||||
if (commentRange.kind === ts.SyntaxKind.MultiLineCommentTrivia) {
|
||||
const commentBody: string = sourceFile.text.slice(commentRange.pos, commentRange.end);
|
||||
|
||||
// Choose the first JSDoc-style comment
|
||||
if (/^\s*\/\*\*/.test(commentBody)) {
|
||||
// But only if it looks like it's trying to be @packageDocumentation
|
||||
// (The TSDoc parser will validate this more rigorously)
|
||||
if (/@packagedocumentation/i.test(commentBody)) {
|
||||
packageCommentRange = commentRange;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!packageCommentRange) {
|
||||
// If we didn't find the @packageDocumentation tag in the expected place, is it in some
|
||||
// wrong place? This sanity check helps people to figure out why there comment isn't working.
|
||||
for (const statement of sourceFile.statements) {
|
||||
const ranges: ts.CommentRange[] = [];
|
||||
ranges.push(...(ts.getLeadingCommentRanges(sourceFile.text, statement.getFullStart()) ?? []));
|
||||
ranges.push(...(ts.getTrailingCommentRanges(sourceFile.text, statement.getEnd()) ?? []));
|
||||
|
||||
for (const commentRange of ranges) {
|
||||
const commentBody: string = sourceFile.text.slice(commentRange.pos, commentRange.end);
|
||||
|
||||
if (/@packagedocumentation/i.test(commentBody)) {
|
||||
collector.messageRouter.addAnalyzerIssueForPosition(
|
||||
ExtractorMessageId.MisplacedPackageTag,
|
||||
'The @packageDocumentation comment must appear at the top of entry point *.d.ts file',
|
||||
sourceFile,
|
||||
commentRange.pos,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return packageCommentRange;
|
||||
}
|
||||
}
|
||||
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"
|
||||
}
|
||||
201
packages/api-extractor/src/api/CompilerState.ts
Normal file
201
packages/api-extractor/src/api/CompilerState.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
/* eslint-disable no-case-declarations */
|
||||
// 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 { JsonFile } from '@rushstack/node-core-library';
|
||||
import colors from 'colors';
|
||||
import * as ts from 'typescript';
|
||||
import type { IExtractorInvokeOptions } from './Extractor.js';
|
||||
import { ExtractorConfig } from './ExtractorConfig.js';
|
||||
|
||||
/**
|
||||
* Options for {@link CompilerState.create}
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface ICompilerStateCreateOptions {
|
||||
/**
|
||||
* Additional .d.ts files to include in the analysis.
|
||||
*/
|
||||
additionalEntryPoints?: string[];
|
||||
|
||||
/**
|
||||
* {@inheritDoc IExtractorInvokeOptions.typescriptCompilerFolder}
|
||||
*/
|
||||
typescriptCompilerFolder?: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* This class represents the TypeScript compiler state. This allows an optimization where multiple invocations
|
||||
* of API Extractor can reuse the same TypeScript compiler analysis.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class CompilerState {
|
||||
/**
|
||||
* The TypeScript compiler's `Program` object, which represents a complete scope of analysis.
|
||||
*/
|
||||
public readonly program: unknown;
|
||||
|
||||
private constructor(properties: CompilerState) {
|
||||
this.program = properties.program;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a compiler state for use with the specified `IExtractorInvokeOptions`.
|
||||
*/
|
||||
public static create(extractorConfig: ExtractorConfig, options?: ICompilerStateCreateOptions): CompilerState {
|
||||
let tsconfig: {} | undefined = extractorConfig.overrideTsconfig;
|
||||
let configBasePath: string = extractorConfig.projectFolder;
|
||||
if (!tsconfig) {
|
||||
// If it wasn't overridden, then load it from disk
|
||||
tsconfig = JsonFile.load(extractorConfig.tsconfigFilePath);
|
||||
configBasePath = path.resolve(path.dirname(extractorConfig.tsconfigFilePath));
|
||||
}
|
||||
|
||||
const commandLine: ts.ParsedCommandLine = ts.parseJsonConfigFileContent(tsconfig, ts.sys, configBasePath);
|
||||
|
||||
if (!commandLine.options.skipLibCheck && extractorConfig.skipLibCheck) {
|
||||
commandLine.options.skipLibCheck = true;
|
||||
console.log(
|
||||
colors.cyan(
|
||||
'API Extractor was invoked with skipLibCheck. This is not recommended and may cause ' +
|
||||
'incorrect type analysis.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const inputFilePaths: string[] = commandLine.fileNames.concat(extractorConfig.mainEntryPointFilePath);
|
||||
if (options?.additionalEntryPoints) {
|
||||
inputFilePaths.push(...options.additionalEntryPoints);
|
||||
}
|
||||
|
||||
// Append the entry points and remove any non-declaration files from the list
|
||||
const analysisFilePaths: string[] = CompilerState._generateFilePathsForAnalysis(inputFilePaths);
|
||||
|
||||
const compilerHost: ts.CompilerHost = CompilerState._createCompilerHost(commandLine, options);
|
||||
|
||||
const program: ts.Program = ts.createProgram(analysisFilePaths, commandLine.options, compilerHost);
|
||||
|
||||
if (commandLine.errors.length > 0) {
|
||||
const errorText: string = ts.flattenDiagnosticMessageText(commandLine.errors[0]!.messageText, '\n');
|
||||
throw new Error(`Error parsing tsconfig.json content: ${errorText}`);
|
||||
}
|
||||
|
||||
return new CompilerState({
|
||||
program,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a list of absolute file paths, return a list containing only the declaration
|
||||
* files. Duplicates are also eliminated.
|
||||
*
|
||||
* @remarks
|
||||
* The tsconfig.json settings specify the compiler's input (a set of *.ts source files,
|
||||
* plus some *.d.ts declaration files used for legacy typings). However API Extractor
|
||||
* analyzes the compiler's output (a set of *.d.ts entry point files, plus any legacy
|
||||
* typings). This requires API Extractor to generate a special file list when it invokes
|
||||
* the compiler.
|
||||
*
|
||||
* Duplicates are removed so that entry points can be appended without worrying whether they
|
||||
* may already appear in the tsconfig.json file list.
|
||||
*/
|
||||
private static _generateFilePathsForAnalysis(inputFilePaths: string[]): string[] {
|
||||
const analysisFilePaths: string[] = [];
|
||||
|
||||
const seenFiles: Set<string> = new Set<string>();
|
||||
|
||||
for (const inputFilePath of inputFilePaths) {
|
||||
const inputFileToUpper: string = inputFilePath.toUpperCase();
|
||||
if (!seenFiles.has(inputFileToUpper)) {
|
||||
seenFiles.add(inputFileToUpper);
|
||||
|
||||
if (!path.isAbsolute(inputFilePath)) {
|
||||
throw new Error('Input file is not an absolute path: ' + inputFilePath);
|
||||
}
|
||||
|
||||
if (ExtractorConfig.hasDtsFileExtension(inputFilePath)) {
|
||||
analysisFilePaths.push(inputFilePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return analysisFilePaths;
|
||||
}
|
||||
|
||||
private static _createCompilerHost(
|
||||
commandLine: ts.ParsedCommandLine,
|
||||
options: IExtractorInvokeOptions | undefined,
|
||||
): ts.CompilerHost {
|
||||
// Create a default CompilerHost that we will override
|
||||
const compilerHost: ts.CompilerHost = ts.createCompilerHost(commandLine.options);
|
||||
|
||||
// Save a copy of the original members. Note that "compilerHost" cannot be the copy, because
|
||||
// createCompilerHost() captures that instance in a closure that is used by the members.
|
||||
const defaultCompilerHost: ts.CompilerHost = { ...compilerHost };
|
||||
|
||||
if (options?.typescriptCompilerFolder) {
|
||||
// Prevent a closure parameter
|
||||
const typescriptCompilerLibFolder: string = path.join(options.typescriptCompilerFolder, 'lib');
|
||||
compilerHost.getDefaultLibLocation = () => typescriptCompilerLibFolder;
|
||||
}
|
||||
|
||||
// Used by compilerHost.fileExists()
|
||||
// .d.ts file path --> whether the file exists
|
||||
const dtsExistsCache: Map<string, boolean> = new Map<string, boolean>();
|
||||
|
||||
// Used by compilerHost.fileExists()
|
||||
// Example: "c:/folder/file.part.ts"
|
||||
const fileExtensionRegExp = /^(?<pathWithoutExtension>.+)(?<fileExtension>\.\w+)$/i;
|
||||
|
||||
compilerHost.fileExists = (fileName: string): boolean => {
|
||||
// In certain deprecated setups, the compiler may write its output files (.js and .d.ts)
|
||||
// in the same folder as the corresponding input file (.ts or .tsx). When following imports,
|
||||
// API Extractor wants to analyze the .d.ts file; however recent versions of the compiler engine
|
||||
// will instead choose the .ts file. To work around this, we hook fileExists() to hide the
|
||||
// existence of those files.
|
||||
|
||||
// Is "fileName" a .d.ts file? The double extension ".d.ts" needs to be matched specially.
|
||||
if (!ExtractorConfig.hasDtsFileExtension(fileName)) {
|
||||
// It's not a .d.ts file. Is the file extension a potential source file?
|
||||
const match: RegExpExecArray | null = fileExtensionRegExp.exec(fileName);
|
||||
if (match?.groups?.pathWithoutExtension && match.groups?.fileExtension) {
|
||||
// Example: "c:/folder/file.part"
|
||||
const pathWithoutExtension: string = match.groups.pathWithoutExtension;
|
||||
// Example: ".ts"
|
||||
const fileExtension: string = match.groups.fileExtension;
|
||||
|
||||
switch (fileExtension.toLocaleLowerCase()) {
|
||||
case '.ts':
|
||||
case '.tsx':
|
||||
case '.js':
|
||||
case '.jsx':
|
||||
// Yes, this is a possible source file. Is there a corresponding .d.ts file in the same folder?
|
||||
const dtsFileName = `${pathWithoutExtension}.d.ts`;
|
||||
|
||||
let dtsFileExists: boolean | undefined = dtsExistsCache.get(dtsFileName);
|
||||
if (dtsFileExists === undefined) {
|
||||
dtsFileExists = defaultCompilerHost.fileExists!(dtsFileName);
|
||||
dtsExistsCache.set(dtsFileName, dtsFileExists);
|
||||
}
|
||||
|
||||
if (dtsFileExists) {
|
||||
// fileName is a potential source file and a corresponding .d.ts file exists.
|
||||
// Thus, API Extractor should ignore this file (so the .d.ts file will get analyzed instead).
|
||||
return false;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall through to the default implementation
|
||||
return defaultCompilerHost.fileExists!(fileName);
|
||||
};
|
||||
|
||||
return compilerHost;
|
||||
}
|
||||
}
|
||||
82
packages/api-extractor/src/api/ConsoleMessageId.ts
Normal file
82
packages/api-extractor/src/api/ConsoleMessageId.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
/**
|
||||
* Unique identifiers for console messages reported by API Extractor.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* These strings are possible values for the {@link ExtractorMessage.messageId} property
|
||||
* when the `ExtractorMessage.category` is {@link ExtractorMessageCategory.Console}.
|
||||
* @public
|
||||
*/
|
||||
export const enum ConsoleMessageId {
|
||||
/**
|
||||
* "You have changed the public API signature for this project. Updating ___"
|
||||
*/
|
||||
ApiReportCopied = 'console-api-report-copied',
|
||||
|
||||
/**
|
||||
* "The API report file was missing, so a new file was created. Please add this file to Git: ___"
|
||||
*/
|
||||
ApiReportCreated = 'console-api-report-created',
|
||||
|
||||
/**
|
||||
* "Unable to create the API report file. Please make sure the target folder exists: ___"
|
||||
*/
|
||||
ApiReportFolderMissing = 'console-api-report-folder-missing',
|
||||
|
||||
/**
|
||||
* "You have changed the public API signature for this project.
|
||||
* Please copy the file ___ to ___, or perform a local build (which does this automatically).
|
||||
* See the Git repo documentation for more info."
|
||||
*
|
||||
* OR
|
||||
*
|
||||
* "The API report file is missing.
|
||||
* Please copy the file ___ to ___, or perform a local build (which does this automatically).
|
||||
* See the Git repo documentation for more info."
|
||||
*/
|
||||
ApiReportNotCopied = 'console-api-report-not-copied',
|
||||
|
||||
/**
|
||||
* "The API report is up to date: ___"
|
||||
*/
|
||||
ApiReportUnchanged = 'console-api-report-unchanged',
|
||||
|
||||
/**
|
||||
* "The target project appears to use TypeScript ___ which is newer than the bundled compiler engine;
|
||||
* consider upgrading API Extractor."
|
||||
*/
|
||||
CompilerVersionNotice = 'console-compiler-version-notice',
|
||||
|
||||
/**
|
||||
* Used for the information printed when the "--diagnostics" flag is enabled.
|
||||
*/
|
||||
Diagnostics = 'console-diagnostics',
|
||||
|
||||
/**
|
||||
* "Found metadata in ___"
|
||||
*/
|
||||
FoundTSDocMetadata = 'console-found-tsdoc-metadata',
|
||||
|
||||
/**
|
||||
* "Analysis will use the bundled TypeScript version ___"
|
||||
*/
|
||||
Preamble = 'console-preamble',
|
||||
|
||||
/**
|
||||
* "Using custom TSDoc config from ___"
|
||||
*/
|
||||
UsingCustomTSDocConfig = 'console-using-custom-tsdoc-config',
|
||||
|
||||
/**
|
||||
* "Writing: ___"
|
||||
*/
|
||||
WritingDocModelFile = 'console-writing-doc-model-file',
|
||||
|
||||
/**
|
||||
* "Writing package typings: ___"
|
||||
*/
|
||||
WritingDtsRollup = 'console-writing-dts-rollup',
|
||||
}
|
||||
467
packages/api-extractor/src/api/Extractor.ts
Normal file
467
packages/api-extractor/src/api/Extractor.ts
Normal file
@@ -0,0 +1,467 @@
|
||||
// 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 { ApiPackage } from '@discordjs/api-extractor-model';
|
||||
import { TSDocConfigFile } from '@microsoft/tsdoc-config';
|
||||
import {
|
||||
FileSystem,
|
||||
type NewlineKind,
|
||||
PackageJsonLookup,
|
||||
type IPackageJson,
|
||||
type INodePackageJson,
|
||||
Path,
|
||||
} from '@rushstack/node-core-library';
|
||||
import * as resolve from 'resolve';
|
||||
import * as semver from 'semver';
|
||||
import * as ts from 'typescript';
|
||||
import { PackageMetadataManager } from '../analyzer/PackageMetadataManager.js';
|
||||
import { Collector } from '../collector/Collector.js';
|
||||
import { MessageRouter } from '../collector/MessageRouter.js';
|
||||
import { SourceMapper } from '../collector/SourceMapper.js';
|
||||
import { DocCommentEnhancer } from '../enhancers/DocCommentEnhancer.js';
|
||||
import { ValidationEnhancer } from '../enhancers/ValidationEnhancer.js';
|
||||
import { ApiModelGenerator } from '../generators/ApiModelGenerator.js';
|
||||
import { ApiReportGenerator } from '../generators/ApiReportGenerator.js';
|
||||
import { DtsRollupGenerator, DtsRollupKind } from '../generators/DtsRollupGenerator.js';
|
||||
import { CompilerState } from './CompilerState.js';
|
||||
import { ConsoleMessageId } from './ConsoleMessageId.js';
|
||||
import { ExtractorConfig } from './ExtractorConfig.js';
|
||||
import type { ExtractorMessage } from './ExtractorMessage.js';
|
||||
|
||||
/**
|
||||
* Runtime options for Extractor.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface IExtractorInvokeOptions {
|
||||
/**
|
||||
* An optional TypeScript compiler state. This allows an optimization where multiple invocations of API Extractor
|
||||
* can reuse the same TypeScript compiler analysis.
|
||||
*/
|
||||
compilerState?: CompilerState;
|
||||
|
||||
/**
|
||||
* Indicates that API Extractor is running as part of a local build, e.g. on developer's
|
||||
* machine.
|
||||
*
|
||||
* @remarks
|
||||
* This disables certain validation that would normally be performed for a ship/production build. For example,
|
||||
* the *.api.md report file is automatically updated in a local build.
|
||||
*
|
||||
* The default value is false.
|
||||
*/
|
||||
localBuild?: boolean;
|
||||
|
||||
/**
|
||||
* An optional callback function that will be called for each `ExtractorMessage` before it is displayed by
|
||||
* API Extractor. The callback can customize the message, handle it, or discard it.
|
||||
*
|
||||
* @remarks
|
||||
* If a `messageCallback` is not provided, then by default API Extractor will print the messages to
|
||||
* the STDERR/STDOUT console.
|
||||
*/
|
||||
messageCallback?(this: void, message: ExtractorMessage): void;
|
||||
|
||||
/**
|
||||
* If true, API Extractor will print diagnostic information used for troubleshooting problems.
|
||||
* These messages will be included as {@link ExtractorLogLevel.Verbose} output.
|
||||
*
|
||||
* @remarks
|
||||
* Setting `showDiagnostics=true` forces `showVerboseMessages=true`.
|
||||
*/
|
||||
showDiagnostics?: boolean;
|
||||
|
||||
/**
|
||||
* If true, API Extractor will include {@link ExtractorLogLevel.Verbose} messages in its output.
|
||||
*/
|
||||
showVerboseMessages?: boolean;
|
||||
|
||||
/**
|
||||
* Specifies an alternate folder path to be used when loading the TypeScript system typings.
|
||||
*
|
||||
* @remarks
|
||||
* API Extractor uses its own TypeScript compiler engine to analyze your project. If your project
|
||||
* is built with a significantly different TypeScript version, sometimes API Extractor may report compilation
|
||||
* errors due to differences in the system typings (e.g. lib.dom.d.ts). You can use the "--typescriptCompilerFolder"
|
||||
* option to specify the folder path where you installed the TypeScript package, and API Extractor's compiler will
|
||||
* use those system typings instead.
|
||||
*/
|
||||
typescriptCompilerFolder?: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* This object represents the outcome of an invocation of API Extractor.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class ExtractorResult {
|
||||
/**
|
||||
* The TypeScript compiler state that was used.
|
||||
*/
|
||||
public readonly compilerState: CompilerState;
|
||||
|
||||
/**
|
||||
* The API Extractor configuration that was used.
|
||||
*/
|
||||
public readonly extractorConfig: ExtractorConfig;
|
||||
|
||||
/**
|
||||
* Whether the invocation of API Extractor was successful. For example, if `succeeded` is false, then the build task
|
||||
* would normally return a nonzero process exit code, indicating that the operation failed.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* Normally the operation "succeeds" if `errorCount` and `warningCount` are both zero. However if
|
||||
* {@link IExtractorInvokeOptions.localBuild} is `true`, then the operation "succeeds" if `errorCount` is zero
|
||||
* (i.e. warnings are ignored).
|
||||
*/
|
||||
public readonly succeeded: boolean;
|
||||
|
||||
/**
|
||||
* Returns true if the API report was found to have changed.
|
||||
*/
|
||||
public readonly apiReportChanged: boolean;
|
||||
|
||||
/**
|
||||
* Reports the number of errors encountered during analysis.
|
||||
*
|
||||
* @remarks
|
||||
* This does not count exceptions, where unexpected issues prematurely abort the operation.
|
||||
*/
|
||||
public readonly errorCount: number;
|
||||
|
||||
/**
|
||||
* Reports the number of warnings encountered during analysis.
|
||||
*
|
||||
* @remarks
|
||||
* This does not count warnings that are emitted in the API report file.
|
||||
*/
|
||||
public readonly warningCount: number;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public constructor(properties: ExtractorResult) {
|
||||
this.compilerState = properties.compilerState;
|
||||
this.extractorConfig = properties.extractorConfig;
|
||||
this.succeeded = properties.succeeded;
|
||||
this.apiReportChanged = properties.apiReportChanged;
|
||||
this.errorCount = properties.errorCount;
|
||||
this.warningCount = properties.warningCount;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The starting point for invoking the API Extractor tool.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class Extractor {
|
||||
/**
|
||||
* Returns the version number of the API Extractor NPM package.
|
||||
*/
|
||||
public static get version(): string {
|
||||
return Extractor._getPackageJson().version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the package name of the API Extractor NPM package.
|
||||
*/
|
||||
public static get packageName(): string {
|
||||
return Extractor._getPackageJson().name;
|
||||
}
|
||||
|
||||
private static _getPackageJson(): IPackageJson {
|
||||
return PackageJsonLookup.loadOwnPackageJson(__dirname);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the api-extractor.json config file from the specified path, and then invoke API Extractor.
|
||||
*/
|
||||
public static loadConfigAndInvoke(configFilePath: string, options?: IExtractorInvokeOptions): ExtractorResult {
|
||||
const extractorConfig: ExtractorConfig = ExtractorConfig.loadFileAndPrepare(configFilePath);
|
||||
|
||||
return Extractor.invoke(extractorConfig, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke API Extractor using an already prepared `ExtractorConfig` object.
|
||||
*/
|
||||
public static invoke(extractorConfig: ExtractorConfig, options?: IExtractorInvokeOptions): ExtractorResult {
|
||||
const ioptions = options ?? {};
|
||||
|
||||
const localBuild: boolean = ioptions.localBuild ?? false;
|
||||
|
||||
let compilerState: CompilerState | undefined;
|
||||
if (ioptions.compilerState) {
|
||||
compilerState = ioptions.compilerState;
|
||||
} else {
|
||||
compilerState = CompilerState.create(extractorConfig, ioptions);
|
||||
}
|
||||
|
||||
const sourceMapper: SourceMapper = new SourceMapper();
|
||||
|
||||
const messageRouter: MessageRouter = new MessageRouter({
|
||||
workingPackageFolder: extractorConfig.packageFolder,
|
||||
messageCallback: ioptions.messageCallback,
|
||||
messagesConfig: extractorConfig.messages || {},
|
||||
showVerboseMessages: Boolean(ioptions.showVerboseMessages),
|
||||
showDiagnostics: Boolean(ioptions.showDiagnostics),
|
||||
tsdocConfiguration: extractorConfig.tsdocConfiguration,
|
||||
sourceMapper,
|
||||
});
|
||||
|
||||
if (
|
||||
extractorConfig.tsdocConfigFile.filePath &&
|
||||
!extractorConfig.tsdocConfigFile.fileNotFound &&
|
||||
!Path.isEqual(extractorConfig.tsdocConfigFile.filePath, ExtractorConfig._tsdocBaseFilePath)
|
||||
) {
|
||||
messageRouter.logVerbose(
|
||||
ConsoleMessageId.UsingCustomTSDocConfig,
|
||||
'Using custom TSDoc config from ' + extractorConfig.tsdocConfigFile.filePath,
|
||||
);
|
||||
}
|
||||
|
||||
this._checkCompilerCompatibility(extractorConfig, messageRouter);
|
||||
|
||||
if (messageRouter.showDiagnostics) {
|
||||
messageRouter.logDiagnostic('');
|
||||
messageRouter.logDiagnosticHeader('Final prepared ExtractorConfig');
|
||||
messageRouter.logDiagnostic(extractorConfig.getDiagnosticDump());
|
||||
messageRouter.logDiagnosticFooter();
|
||||
|
||||
messageRouter.logDiagnosticHeader('Compiler options');
|
||||
const serializedCompilerOptions: object = MessageRouter.buildJsonDumpObject(
|
||||
(compilerState.program as ts.Program).getCompilerOptions(),
|
||||
);
|
||||
messageRouter.logDiagnostic(JSON.stringify(serializedCompilerOptions, undefined, 2));
|
||||
messageRouter.logDiagnosticFooter();
|
||||
|
||||
messageRouter.logDiagnosticHeader('TSDoc configuration');
|
||||
// Convert the TSDocConfiguration into a tsdoc.json representation
|
||||
const combinedConfigFile: TSDocConfigFile = TSDocConfigFile.loadFromParser(extractorConfig.tsdocConfiguration);
|
||||
const serializedTSDocConfig: object = MessageRouter.buildJsonDumpObject(combinedConfigFile.saveToObject());
|
||||
messageRouter.logDiagnostic(JSON.stringify(serializedTSDocConfig, undefined, 2));
|
||||
messageRouter.logDiagnosticFooter();
|
||||
}
|
||||
|
||||
const collector: Collector = new Collector({
|
||||
program: compilerState.program as ts.Program,
|
||||
messageRouter,
|
||||
extractorConfig,
|
||||
sourceMapper,
|
||||
});
|
||||
|
||||
collector.analyze();
|
||||
|
||||
DocCommentEnhancer.analyze(collector);
|
||||
ValidationEnhancer.analyze(collector);
|
||||
|
||||
const modelBuilder: ApiModelGenerator = new ApiModelGenerator(collector);
|
||||
const apiPackage: ApiPackage = modelBuilder.buildApiPackage();
|
||||
|
||||
if (messageRouter.showDiagnostics) {
|
||||
messageRouter.logDiagnostic(''); // skip a line after any diagnostic messages
|
||||
}
|
||||
|
||||
if (extractorConfig.docModelEnabled) {
|
||||
messageRouter.logVerbose(ConsoleMessageId.WritingDocModelFile, 'Writing: ' + extractorConfig.apiJsonFilePath);
|
||||
apiPackage.saveToJsonFile(extractorConfig.apiJsonFilePath, {
|
||||
toolPackage: Extractor.packageName,
|
||||
toolVersion: Extractor.version,
|
||||
|
||||
newlineConversion: extractorConfig.newlineKind,
|
||||
ensureFolderExists: true,
|
||||
testMode: extractorConfig.testMode,
|
||||
});
|
||||
}
|
||||
|
||||
let apiReportChanged = false;
|
||||
|
||||
if (extractorConfig.apiReportEnabled) {
|
||||
const actualApiReportPath: string = extractorConfig.reportTempFilePath;
|
||||
const actualApiReportShortPath: string = extractorConfig._getShortFilePath(extractorConfig.reportTempFilePath);
|
||||
|
||||
const expectedApiReportPath: string = extractorConfig.reportFilePath;
|
||||
const expectedApiReportShortPath: string = extractorConfig._getShortFilePath(extractorConfig.reportFilePath);
|
||||
|
||||
const actualApiReportContent: string = ApiReportGenerator.generateReviewFileContent(collector);
|
||||
|
||||
// Write the actual file
|
||||
FileSystem.writeFile(actualApiReportPath, actualApiReportContent, {
|
||||
ensureFolderExists: true,
|
||||
convertLineEndings: extractorConfig.newlineKind,
|
||||
});
|
||||
|
||||
// Compare it against the expected file
|
||||
if (FileSystem.exists(expectedApiReportPath)) {
|
||||
const expectedApiReportContent: string = FileSystem.readFile(expectedApiReportPath);
|
||||
|
||||
if (ApiReportGenerator.areEquivalentApiFileContents(actualApiReportContent, expectedApiReportContent)) {
|
||||
messageRouter.logVerbose(
|
||||
ConsoleMessageId.ApiReportUnchanged,
|
||||
`The API report is up to date: ${actualApiReportShortPath}`,
|
||||
);
|
||||
} else {
|
||||
apiReportChanged = true;
|
||||
|
||||
if (localBuild) {
|
||||
// For a local build, just copy the file automatically.
|
||||
messageRouter.logWarning(
|
||||
ConsoleMessageId.ApiReportCopied,
|
||||
`You have changed the public API signature for this project. Updating ${expectedApiReportShortPath}`,
|
||||
);
|
||||
|
||||
FileSystem.writeFile(expectedApiReportPath, actualApiReportContent, {
|
||||
ensureFolderExists: true,
|
||||
convertLineEndings: extractorConfig.newlineKind,
|
||||
});
|
||||
} else {
|
||||
// For a production build, issue a warning that will break the CI build.
|
||||
messageRouter.logWarning(
|
||||
ConsoleMessageId.ApiReportNotCopied,
|
||||
'You have changed the public API signature for this project.' +
|
||||
` Please copy the file "${actualApiReportShortPath}" to "${expectedApiReportShortPath}",` +
|
||||
` or perform a local build (which does this automatically).` +
|
||||
` See the Git repo documentation for more info.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// The target file does not exist, so we are setting up the API review file for the first time.
|
||||
//
|
||||
// NOTE: People sometimes make a mistake where they move a project and forget to update the "reportFolder"
|
||||
// setting, which causes a new file to silently get written to the wrong place. This can be confusing.
|
||||
// Thus we treat the initial creation of the file specially.
|
||||
apiReportChanged = true;
|
||||
|
||||
if (localBuild) {
|
||||
const expectedApiReportFolder: string = path.dirname(expectedApiReportPath);
|
||||
if (FileSystem.exists(expectedApiReportFolder)) {
|
||||
FileSystem.writeFile(expectedApiReportPath, actualApiReportContent, {
|
||||
convertLineEndings: extractorConfig.newlineKind,
|
||||
});
|
||||
messageRouter.logWarning(
|
||||
ConsoleMessageId.ApiReportCreated,
|
||||
'The API report file was missing, so a new file was created. Please add this file to Git:\n' +
|
||||
expectedApiReportPath,
|
||||
);
|
||||
} else {
|
||||
messageRouter.logError(
|
||||
ConsoleMessageId.ApiReportFolderMissing,
|
||||
'Unable to create the API report file. Please make sure the target folder exists:\n' +
|
||||
expectedApiReportFolder,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// For a production build, issue a warning that will break the CI build.
|
||||
messageRouter.logWarning(
|
||||
ConsoleMessageId.ApiReportNotCopied,
|
||||
'The API report file is missing.' +
|
||||
` Please copy the file "${actualApiReportShortPath}" to "${expectedApiReportShortPath}",` +
|
||||
` or perform a local build (which does this automatically).` +
|
||||
` See the Git repo documentation for more info.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (extractorConfig.rollupEnabled) {
|
||||
Extractor._generateRollupDtsFile(
|
||||
collector,
|
||||
extractorConfig.publicTrimmedFilePath,
|
||||
DtsRollupKind.PublicRelease,
|
||||
extractorConfig.newlineKind,
|
||||
);
|
||||
Extractor._generateRollupDtsFile(
|
||||
collector,
|
||||
extractorConfig.alphaTrimmedFilePath,
|
||||
DtsRollupKind.AlphaRelease,
|
||||
extractorConfig.newlineKind,
|
||||
);
|
||||
Extractor._generateRollupDtsFile(
|
||||
collector,
|
||||
extractorConfig.betaTrimmedFilePath,
|
||||
DtsRollupKind.BetaRelease,
|
||||
extractorConfig.newlineKind,
|
||||
);
|
||||
Extractor._generateRollupDtsFile(
|
||||
collector,
|
||||
extractorConfig.untrimmedFilePath,
|
||||
DtsRollupKind.InternalRelease,
|
||||
extractorConfig.newlineKind,
|
||||
);
|
||||
}
|
||||
|
||||
if (extractorConfig.tsdocMetadataEnabled) {
|
||||
// Write the tsdoc-metadata.json file for this project
|
||||
PackageMetadataManager.writeTsdocMetadataFile(extractorConfig.tsdocMetadataFilePath, extractorConfig.newlineKind);
|
||||
}
|
||||
|
||||
// Show all the messages that we collected during analysis
|
||||
messageRouter.handleRemainingNonConsoleMessages();
|
||||
|
||||
// Determine success
|
||||
let succeeded: boolean;
|
||||
if (localBuild) {
|
||||
// For a local build, fail if there were errors (but ignore warnings)
|
||||
succeeded = messageRouter.errorCount === 0;
|
||||
} else {
|
||||
// For a production build, fail if there were any errors or warnings
|
||||
succeeded = messageRouter.errorCount + messageRouter.warningCount === 0;
|
||||
}
|
||||
|
||||
return new ExtractorResult({
|
||||
compilerState,
|
||||
extractorConfig,
|
||||
succeeded,
|
||||
apiReportChanged,
|
||||
errorCount: messageRouter.errorCount,
|
||||
warningCount: messageRouter.warningCount,
|
||||
});
|
||||
}
|
||||
|
||||
private static _checkCompilerCompatibility(extractorConfig: ExtractorConfig, messageRouter: MessageRouter): void {
|
||||
messageRouter.logInfo(ConsoleMessageId.Preamble, `Analysis will use the bundled TypeScript version ${ts.version}`);
|
||||
|
||||
try {
|
||||
const typescriptPath: string = resolve.sync('typescript', {
|
||||
basedir: extractorConfig.projectFolder,
|
||||
preserveSymlinks: false,
|
||||
});
|
||||
const packageJsonLookup: PackageJsonLookup = new PackageJsonLookup();
|
||||
const packageJson: INodePackageJson | undefined = packageJsonLookup.tryLoadNodePackageJsonFor(typescriptPath);
|
||||
if (packageJson?.version && semver.valid(packageJson.version)) {
|
||||
// Consider a newer MINOR release to be incompatible
|
||||
const ourMajor: number = semver.major(ts.version);
|
||||
const ourMinor: number = semver.minor(ts.version);
|
||||
|
||||
const theirMajor: number = semver.major(packageJson.version);
|
||||
const theirMinor: number = semver.minor(packageJson.version);
|
||||
|
||||
if (theirMajor > ourMajor || (theirMajor === ourMajor && theirMinor > ourMinor)) {
|
||||
messageRouter.logInfo(
|
||||
ConsoleMessageId.CompilerVersionNotice,
|
||||
`*** The target project appears to use TypeScript ${packageJson.version} which is newer than the` +
|
||||
` bundled compiler engine; consider upgrading API Extractor.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// The compiler detection heuristic is not expected to work in many configurations
|
||||
}
|
||||
}
|
||||
|
||||
private static _generateRollupDtsFile(
|
||||
collector: Collector,
|
||||
outputPath: string,
|
||||
dtsKind: DtsRollupKind,
|
||||
newlineKind: NewlineKind,
|
||||
): void {
|
||||
if (outputPath !== '') {
|
||||
collector.messageRouter.logVerbose(ConsoleMessageId.WritingDtsRollup, `Writing package typings: ${outputPath}`);
|
||||
DtsRollupGenerator.writeTypingsFile(collector, outputPath, dtsKind, newlineKind);
|
||||
}
|
||||
}
|
||||
}
|
||||
1196
packages/api-extractor/src/api/ExtractorConfig.ts
Normal file
1196
packages/api-extractor/src/api/ExtractorConfig.ts
Normal file
File diff suppressed because it is too large
Load Diff
48
packages/api-extractor/src/api/ExtractorLogLevel.ts
Normal file
48
packages/api-extractor/src/api/ExtractorLogLevel.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
/**
|
||||
* Used with {@link IConfigMessageReportingRule.logLevel} and {@link IExtractorInvokeOptions.messageCallback}.
|
||||
*
|
||||
* @remarks
|
||||
* This is part of the {@link IConfigFile} structure.
|
||||
* @public
|
||||
*/
|
||||
export const enum ExtractorLogLevel {
|
||||
/**
|
||||
* The message will be displayed as an error.
|
||||
*
|
||||
* @remarks
|
||||
* Errors typically cause the build to fail and return a nonzero exit code.
|
||||
*/
|
||||
Error = 'error',
|
||||
|
||||
/**
|
||||
* The message will be displayed as an informational message.
|
||||
*
|
||||
* @remarks
|
||||
* Informational messages may contain newlines to ensure nice formatting of the output,
|
||||
* however word-wrapping is the responsibility of the message handler.
|
||||
*/
|
||||
Info = 'info',
|
||||
|
||||
/**
|
||||
* The message will be discarded entirely.
|
||||
*/
|
||||
None = 'none',
|
||||
|
||||
/**
|
||||
* The message will be displayed only when "verbose" output is requested, e.g. using the `--verbose`
|
||||
* command line option.
|
||||
*/
|
||||
Verbose = 'verbose',
|
||||
|
||||
/**
|
||||
* The message will be displayed as an warning.
|
||||
*
|
||||
* @remarks
|
||||
* Warnings typically cause a production build fail and return a nonzero exit code. For a non-production build
|
||||
* (e.g. using the `--local` option with `api-extractor run`), the warning is displayed but the build will not fail.
|
||||
*/
|
||||
Warning = 'warning',
|
||||
}
|
||||
238
packages/api-extractor/src/api/ExtractorMessage.ts
Normal file
238
packages/api-extractor/src/api/ExtractorMessage.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
import type * as tsdoc from '@microsoft/tsdoc';
|
||||
import { SourceFileLocationFormatter } from '../analyzer/SourceFileLocationFormatter.js';
|
||||
import type { ConsoleMessageId } from './ConsoleMessageId.js';
|
||||
import { ExtractorLogLevel } from './ExtractorLogLevel.js';
|
||||
import type { ExtractorMessageId } from './ExtractorMessageId.js';
|
||||
|
||||
/**
|
||||
* Used by {@link ExtractorMessage.properties}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface IExtractorMessageProperties {
|
||||
/**
|
||||
* A declaration can have multiple names if it is exported more than once.
|
||||
* If an `ExtractorMessage` applies to a specific export name, this property can indicate that.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* Used by {@link ExtractorMessageId.InternalMissingUnderscore}.
|
||||
*/
|
||||
readonly exportName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Specifies a category of messages for use with {@link ExtractorMessage}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const enum ExtractorMessageCategory {
|
||||
/**
|
||||
* Messages originating from the TypeScript compiler.
|
||||
*
|
||||
* @remarks
|
||||
* These strings begin with the prefix "TS" and have a numeric error code.
|
||||
* Example: `TS2551`
|
||||
*/
|
||||
Compiler = 'Compiler',
|
||||
|
||||
/**
|
||||
* Console messages communicate the progress of the overall operation. They may include newlines to ensure
|
||||
* nice formatting. They are output in real time, and cannot be routed to the API Report file.
|
||||
*
|
||||
* @remarks
|
||||
* These strings begin with the prefix "console-".
|
||||
* Example: `console-writing-typings-file`
|
||||
*/
|
||||
Console = 'console',
|
||||
|
||||
/**
|
||||
* Messages related to API Extractor's analysis.
|
||||
*
|
||||
* @remarks
|
||||
* These strings begin with the prefix "ae-".
|
||||
* Example: `ae-extra-release-tag`
|
||||
*/
|
||||
Extractor = 'Extractor',
|
||||
|
||||
/**
|
||||
* Messages related to parsing of TSDoc comments.
|
||||
*
|
||||
* @remarks
|
||||
* These strings begin with the prefix "tsdoc-".
|
||||
* Example: `tsdoc-link-tag-unescaped-text`
|
||||
*/
|
||||
TSDoc = 'TSDoc',
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor options for `ExtractorMessage`.
|
||||
*/
|
||||
export interface IExtractorMessageOptions {
|
||||
category: ExtractorMessageCategory;
|
||||
logLevel?: ExtractorLogLevel;
|
||||
messageId: ConsoleMessageId | ExtractorMessageId | tsdoc.TSDocMessageId | string;
|
||||
properties?: IExtractorMessageProperties | undefined;
|
||||
sourceFileColumn?: number;
|
||||
sourceFileLine?: number;
|
||||
sourceFilePath?: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* This object is used to report an error or warning that occurred during API Extractor's analysis.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class ExtractorMessage {
|
||||
private _handled: boolean;
|
||||
|
||||
private _logLevel: ExtractorLogLevel;
|
||||
|
||||
/**
|
||||
* The category of issue.
|
||||
*/
|
||||
public readonly category: ExtractorMessageCategory;
|
||||
|
||||
/**
|
||||
* A text string that uniquely identifies the issue type. This identifier can be used to suppress
|
||||
* or configure the reporting of issues, and also to search for help about an issue.
|
||||
*/
|
||||
public readonly messageId: ConsoleMessageId | ExtractorMessageId | tsdoc.TSDocMessageId | string;
|
||||
|
||||
/**
|
||||
* The text description of this issue.
|
||||
*/
|
||||
public readonly text: string;
|
||||
|
||||
/**
|
||||
* The absolute path to the affected input source file, if there is one.
|
||||
*/
|
||||
public readonly sourceFilePath: string | undefined;
|
||||
|
||||
/**
|
||||
* The line number where the issue occurred in the input source file. This is not used if `sourceFilePath`
|
||||
* is undefined. The first line number is 1.
|
||||
*/
|
||||
public readonly sourceFileLine: number | undefined;
|
||||
|
||||
/**
|
||||
* The column number where the issue occurred in the input source file. This is not used if `sourceFilePath`
|
||||
* is undefined. The first column number is 1.
|
||||
*/
|
||||
public readonly sourceFileColumn: number | undefined;
|
||||
|
||||
/**
|
||||
* Additional contextual information about the message that may be useful when reporting errors.
|
||||
* All properties are optional.
|
||||
*/
|
||||
public readonly properties: IExtractorMessageProperties;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public constructor(options: IExtractorMessageOptions) {
|
||||
this.category = options.category;
|
||||
this.messageId = options.messageId;
|
||||
this.text = options.text;
|
||||
this.sourceFilePath = options.sourceFilePath;
|
||||
this.sourceFileLine = options.sourceFileLine;
|
||||
this.sourceFileColumn = options.sourceFileColumn;
|
||||
this.properties = options.properties ?? {};
|
||||
|
||||
this._handled = false;
|
||||
this._logLevel = options.logLevel ?? ExtractorLogLevel.None;
|
||||
}
|
||||
|
||||
/**
|
||||
* If the {@link IExtractorInvokeOptions.messageCallback} sets this property to true, it will prevent the message
|
||||
* from being displayed by API Extractor.
|
||||
*
|
||||
* @remarks
|
||||
* If the `messageCallback` routes the message to a custom handler (e.g. a toolchain logger), it should
|
||||
* assign `handled = true` to prevent API Extractor from displaying it. Assigning `handled = true` for all messages
|
||||
* would effectively disable all console output from the `Extractor` API.
|
||||
*
|
||||
* If `handled` is set to true, the message will still be included in the count of errors/warnings;
|
||||
* to discard a message entirely, instead assign `logLevel = none`.
|
||||
*/
|
||||
public get handled(): boolean {
|
||||
return this._handled;
|
||||
}
|
||||
|
||||
public set handled(value: boolean) {
|
||||
if (this._handled && !value) {
|
||||
throw new Error('One a message has been marked as handled, the "handled" property cannot be set to false');
|
||||
}
|
||||
|
||||
this._handled = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Specifies how the message should be reported.
|
||||
*
|
||||
* @remarks
|
||||
* If the {@link IExtractorInvokeOptions.messageCallback} handles the message (i.e. sets `handled = true`),
|
||||
* it can use the `logLevel` to determine how to display the message.
|
||||
*
|
||||
* Alternatively, if API Extractor is handling the message, the `messageCallback` could assign `logLevel` to change
|
||||
* how it will be processed. However, in general the recommended practice is to configure message routing
|
||||
* using the `messages` section in api-extractor.json.
|
||||
*
|
||||
* To discard a message entirely, assign `logLevel = none`.
|
||||
*/
|
||||
public get logLevel(): ExtractorLogLevel {
|
||||
return this._logLevel;
|
||||
}
|
||||
|
||||
public set logLevel(value: ExtractorLogLevel) {
|
||||
switch (value) {
|
||||
case ExtractorLogLevel.Error:
|
||||
case ExtractorLogLevel.Info:
|
||||
case ExtractorLogLevel.None:
|
||||
case ExtractorLogLevel.Verbose:
|
||||
case ExtractorLogLevel.Warning:
|
||||
break;
|
||||
default:
|
||||
throw new Error('Invalid log level');
|
||||
}
|
||||
|
||||
this._logLevel = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the message formatted with its identifier and file position.
|
||||
*
|
||||
* @remarks
|
||||
* Example:
|
||||
* ```
|
||||
* src/folder/File.ts:123:4 - (ae-extra-release-tag) The doc comment should not contain more than one release tag.
|
||||
* ```
|
||||
*/
|
||||
public formatMessageWithLocation(workingPackageFolderPath: string | undefined): string {
|
||||
let result = '';
|
||||
|
||||
if (this.sourceFilePath) {
|
||||
result += SourceFileLocationFormatter.formatPath(this.sourceFilePath, {
|
||||
sourceFileLine: this.sourceFileLine,
|
||||
sourceFileColumn: this.sourceFileColumn,
|
||||
workingPackageFolderPath,
|
||||
});
|
||||
|
||||
if (result.length > 0) {
|
||||
result += ' - ';
|
||||
}
|
||||
}
|
||||
|
||||
result += this.formatMessageWithoutLocation();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public formatMessageWithoutLocation(): string {
|
||||
return `(${this.messageId}) ${this.text}`;
|
||||
}
|
||||
}
|
||||
145
packages/api-extractor/src/api/ExtractorMessageId.ts
Normal file
145
packages/api-extractor/src/api/ExtractorMessageId.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
/**
|
||||
* Unique identifiers for messages reported by API Extractor during its analysis.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* These strings are possible values for the {@link ExtractorMessage.messageId} property
|
||||
* when the `ExtractorMessage.category` is {@link ExtractorMessageCategory.Extractor}.
|
||||
* @public
|
||||
*/
|
||||
export const enum ExtractorMessageId {
|
||||
/**
|
||||
* "The `@inheritDoc` tag for ___ refers to its own declaration."
|
||||
*/
|
||||
CyclicInheritDoc = 'ae-cyclic-inherit-doc',
|
||||
|
||||
/**
|
||||
* "This symbol has another declaration with a different release tag."
|
||||
*/
|
||||
DifferentReleaseTags = 'ae-different-release-tags',
|
||||
|
||||
/**
|
||||
* "The doc comment should not contain more than one release tag."
|
||||
*/
|
||||
ExtraReleaseTag = 'ae-extra-release-tag',
|
||||
|
||||
/**
|
||||
* "The symbol ___ needs to be exported by the entry point ___."
|
||||
*/
|
||||
ForgottenExport = 'ae-forgotten-export',
|
||||
|
||||
/**
|
||||
* "The symbol ___ is marked as ___, but its signature references ___ which is marked as ___."
|
||||
*/
|
||||
IncompatibleReleaseTags = 'ae-incompatible-release-tags',
|
||||
|
||||
/**
|
||||
* "The name ___ should be prefixed with an underscore because the declaration is marked as `@internal`."
|
||||
*/
|
||||
InternalMissingUnderscore = 'ae-internal-missing-underscore',
|
||||
|
||||
/**
|
||||
* "Mixed release tags are not allowed for ___ because one of its declarations is marked as `@internal`."
|
||||
*/
|
||||
InternalMixedReleaseTag = 'ae-internal-mixed-release-tag',
|
||||
|
||||
/**
|
||||
* "The `@packageDocumentation` comment must appear at the top of entry point *.d.ts file."
|
||||
*/
|
||||
MisplacedPackageTag = 'ae-misplaced-package-tag',
|
||||
|
||||
/**
|
||||
* "The property ___ has a setter but no getter."
|
||||
*/
|
||||
MissingGetter = 'ae-missing-getter',
|
||||
|
||||
/**
|
||||
* "___ is part of the package's API, but it is missing a release tag (`@alpha`, `@beta`, `@public`, or `@internal`)."
|
||||
*/
|
||||
MissingReleaseTag = 'ae-missing-release-tag',
|
||||
|
||||
/**
|
||||
* "The `@preapproved` tag cannot be applied to ___ without an `@internal` release tag."
|
||||
*/
|
||||
PreapprovedBadReleaseTag = 'ae-preapproved-bad-release-tag',
|
||||
|
||||
/**
|
||||
* "The `@preapproved` tag cannot be applied to ___ because it is not a supported declaration type."
|
||||
*/
|
||||
PreapprovedUnsupportedType = 'ae-preapproved-unsupported-type',
|
||||
|
||||
/**
|
||||
* "The doc comment for the property ___ must appear on the getter, not the setter."
|
||||
*/
|
||||
SetterWithDocs = 'ae-setter-with-docs',
|
||||
|
||||
/**
|
||||
* "Missing documentation for ___."
|
||||
*
|
||||
* @remarks
|
||||
* The `ae-undocumented` message is only generated if the API report feature is enabled.
|
||||
*
|
||||
* Because the API report file already annotates undocumented items with `// (undocumented)`,
|
||||
* the `ae-undocumented` message is not logged by default. To see it, add a setting such as:
|
||||
* ```json
|
||||
* "messages": {
|
||||
* "extractorMessageReporting": {
|
||||
* "ae-undocumented": {
|
||||
* "logLevel": "warning"
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
Undocumented = 'ae-undocumented',
|
||||
|
||||
/**
|
||||
* "The `@inheritDoc` tag needs a TSDoc declaration reference; signature matching is not supported yet."
|
||||
*
|
||||
* @privateRemarks
|
||||
* In the future, we will implement signature matching so that you can write `{@inheritDoc}` and API Extractor
|
||||
* will find a corresponding member from a base class (or implemented interface). Until then, the tag
|
||||
* always needs an explicit declaration reference such as `{@inhertDoc MyBaseClass.sameMethod}`.
|
||||
*/
|
||||
UnresolvedInheritDocBase = 'ae-unresolved-inheritdoc-base',
|
||||
|
||||
/**
|
||||
* "The `@inheritDoc` reference could not be resolved."
|
||||
*/
|
||||
UnresolvedInheritDocReference = 'ae-unresolved-inheritdoc-reference',
|
||||
|
||||
/**
|
||||
* "The `@link` reference could not be resolved."
|
||||
*/
|
||||
UnresolvedLink = 'ae-unresolved-link',
|
||||
|
||||
/**
|
||||
* "Incorrect file type; API Extractor expects to analyze compiler outputs with the .d.ts file extension.
|
||||
* Troubleshooting tips: `https://api-extractor.com/link/dts-error`"
|
||||
*/
|
||||
WrongInputFileType = 'ae-wrong-input-file-type',
|
||||
}
|
||||
|
||||
export const allExtractorMessageIds: Set<string> = new Set<string>([
|
||||
'ae-extra-release-tag',
|
||||
'ae-undocumented',
|
||||
'ae-different-release-tags',
|
||||
'ae-incompatible-release-tags',
|
||||
'ae-missing-release-tag',
|
||||
'ae-misplaced-package-tag',
|
||||
'ae-forgotten-export',
|
||||
'ae-internal-missing-underscore',
|
||||
'ae-internal-mixed-release-tag',
|
||||
'ae-preapproved-unsupported-type',
|
||||
'ae-preapproved-bad-release-tag',
|
||||
'ae-unresolved-inheritdoc-reference',
|
||||
'ae-unresolved-inheritdoc-base',
|
||||
'ae-cyclic-inherit-doc',
|
||||
'ae-unresolved-link',
|
||||
'ae-setter-with-docs',
|
||||
'ae-missing-getter',
|
||||
'ae-wrong-input-file-type',
|
||||
]);
|
||||
448
packages/api-extractor/src/api/IConfigFile.ts
Normal file
448
packages/api-extractor/src/api/IConfigFile.ts
Normal file
@@ -0,0 +1,448 @@
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
import type { EnumMemberOrder } from '@discordjs/api-extractor-model';
|
||||
import type { ExtractorLogLevel } from './ExtractorLogLevel.js';
|
||||
|
||||
/**
|
||||
* Determines how the TypeScript compiler engine will be invoked by API Extractor.
|
||||
*
|
||||
* @remarks
|
||||
* This is part of the {@link IConfigFile} structure.
|
||||
* @public
|
||||
*/
|
||||
export interface IConfigCompiler {
|
||||
/**
|
||||
* Provides a compiler configuration that will be used instead of reading the tsconfig.json file from disk.
|
||||
*
|
||||
* @remarks
|
||||
* The value must conform to the TypeScript tsconfig schema:
|
||||
*
|
||||
* http://json.schemastore.org/tsconfig
|
||||
*
|
||||
* If omitted, then the tsconfig.json file will instead be read from the projectFolder.
|
||||
*/
|
||||
overrideTsconfig?: {};
|
||||
|
||||
/**
|
||||
* This option causes the compiler to be invoked with the `--skipLibCheck` option.
|
||||
*
|
||||
* @remarks
|
||||
* This option is not recommended and may cause API Extractor to produce incomplete or incorrect declarations,
|
||||
* but it may be required when dependencies contain declarations that are incompatible with the TypeScript engine
|
||||
* that API Extractor uses for its analysis. Where possible, the underlying issue should be fixed rather than
|
||||
* relying on skipLibCheck.
|
||||
*/
|
||||
skipLibCheck?: boolean;
|
||||
|
||||
/**
|
||||
* Specifies the path to the tsconfig.json file to be used by API Extractor when analyzing the project.
|
||||
*
|
||||
* @remarks
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as `<projectFolder>`.
|
||||
*
|
||||
* Note: This setting will be ignored if `overrideTsconfig` is used.
|
||||
*/
|
||||
tsconfigFilePath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures how the API report files (*.api.md) will be generated.
|
||||
*
|
||||
* @remarks
|
||||
* This is part of the {@link IConfigFile} structure.
|
||||
* @public
|
||||
*/
|
||||
export interface IConfigApiReport {
|
||||
/**
|
||||
* Whether to generate an API report.
|
||||
*/
|
||||
enabled: boolean;
|
||||
|
||||
/**
|
||||
* Whether "forgotten exports" should be included in the API report file.
|
||||
*
|
||||
* @remarks
|
||||
* Forgotten exports are declarations flagged with `ae-forgotten-export` warnings. See
|
||||
* https://api-extractor.com/pages/messages/ae-forgotten-export/ to learn more.
|
||||
* @defaultValue `false`
|
||||
*/
|
||||
includeForgottenExports?: boolean;
|
||||
|
||||
/**
|
||||
* The filename for the API report files. It will be combined with `reportFolder` or `reportTempFolder` to produce
|
||||
* a full output filename.
|
||||
*
|
||||
* @remarks
|
||||
* The file extension should be ".api.md", and the string should not contain a path separator such as `\` or `/`.
|
||||
*/
|
||||
reportFileName?: string;
|
||||
|
||||
/**
|
||||
* Specifies the folder where the API report file is written. The file name portion is determined by
|
||||
* the `reportFileName` setting.
|
||||
*
|
||||
* @remarks
|
||||
* The API report file is normally tracked by Git. Changes to it can be used to trigger a branch policy,
|
||||
* e.g. for an API review.
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as `<projectFolder>`.
|
||||
*/
|
||||
reportFolder?: string;
|
||||
|
||||
/**
|
||||
* Specifies the folder where the temporary report file is written. The file name portion is determined by
|
||||
* the `reportFileName` setting.
|
||||
*
|
||||
* @remarks
|
||||
* After the temporary file is written to disk, it is compared with the file in the `reportFolder`.
|
||||
* If they are different, a production build will fail.
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as `<projectFolder>`.
|
||||
*/
|
||||
reportTempFolder?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures how the doc model file (*.api.json) will be generated.
|
||||
*
|
||||
* @remarks
|
||||
* This is part of the {@link IConfigFile} structure.
|
||||
* @public
|
||||
*/
|
||||
export interface IConfigDocModel {
|
||||
/**
|
||||
* The output path for the doc model file. The file extension should be ".api.json".
|
||||
*
|
||||
* @remarks
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as `<projectFolder>`.
|
||||
*/
|
||||
apiJsonFilePath?: string;
|
||||
|
||||
/**
|
||||
* Whether to generate a doc model file.
|
||||
*/
|
||||
enabled: boolean;
|
||||
|
||||
/**
|
||||
* Whether "forgotten exports" should be included in the doc model file.
|
||||
*
|
||||
* @remarks
|
||||
* Forgotten exports are declarations flagged with `ae-forgotten-export` warnings. See
|
||||
* https://api-extractor.com/pages/messages/ae-forgotten-export/ to learn more.
|
||||
* @defaultValue `false`
|
||||
*/
|
||||
includeForgottenExports?: boolean;
|
||||
|
||||
/**
|
||||
* The base URL where the project's source code can be viewed on a website such as GitHub or
|
||||
* Azure DevOps. This URL path corresponds to the `<projectFolder>` path on disk.
|
||||
*
|
||||
* @remarks
|
||||
* This URL is concatenated with the file paths serialized to the doc model to produce URL file paths to individual API items.
|
||||
* For example, if the `projectFolderUrl` is "https://github.com/microsoft/rushstack/tree/main/apps/api-extractor" and an API
|
||||
* item's file path is "api/ExtractorConfig.ts", the full URL file path would be
|
||||
* "https://github.com/microsoft/rushstack/tree/main/apps/api-extractor/api/ExtractorConfig.js".
|
||||
*
|
||||
* Can be omitted if you don't need source code links in your API documentation reference.
|
||||
*/
|
||||
projectFolderUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures how the .d.ts rollup file will be generated.
|
||||
*
|
||||
* @remarks
|
||||
* This is part of the {@link IConfigFile} structure.
|
||||
* @public
|
||||
*/
|
||||
export interface IConfigDtsRollup {
|
||||
/**
|
||||
* Specifies the output path for a .d.ts rollup file to be generated with trimming for an "alpha" release.
|
||||
*
|
||||
* @remarks
|
||||
* This file will include only declarations that are marked as `@public`, `@beta`, or `@alpha`.
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as `<projectFolder>`.
|
||||
*/
|
||||
alphaTrimmedFilePath?: string;
|
||||
|
||||
/**
|
||||
* Specifies the output path for a .d.ts rollup file to be generated with trimming for a "beta" release.
|
||||
*
|
||||
* @remarks
|
||||
* This file will include only declarations that are marked as `@public` or `@beta`.
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as `<projectFolder>`.
|
||||
*/
|
||||
betaTrimmedFilePath?: string;
|
||||
|
||||
/**
|
||||
* Whether to generate the .d.ts rollup file.
|
||||
*/
|
||||
enabled: boolean;
|
||||
|
||||
/**
|
||||
* When a declaration is trimmed, by default it will be replaced by a code comment such as
|
||||
* "Excluded from this release type: exampleMember". Set "omitTrimmingComments" to true to remove the
|
||||
* declaration completely.
|
||||
*/
|
||||
omitTrimmingComments?: boolean;
|
||||
|
||||
/**
|
||||
* Specifies the output path for a .d.ts rollup file to be generated with trimming for a "public" release.
|
||||
*
|
||||
* @remarks
|
||||
* This file will include only declarations that are marked as `@public`.
|
||||
*
|
||||
* If the path is an empty string, then this file will not be written.
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as `<projectFolder>`.
|
||||
*/
|
||||
publicTrimmedFilePath?: string;
|
||||
|
||||
/**
|
||||
* Specifies the output path for a .d.ts rollup file to be generated without any trimming.
|
||||
*
|
||||
* @remarks
|
||||
* This file will include all declarations that are exported by the main entry point.
|
||||
*
|
||||
* If the path is an empty string, then this file will not be written.
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as `<projectFolder>`.
|
||||
*/
|
||||
untrimmedFilePath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures how the tsdoc-metadata.json file will be generated.
|
||||
*
|
||||
* @remarks
|
||||
* This is part of the {@link IConfigFile} structure.
|
||||
* @public
|
||||
*/
|
||||
export interface IConfigTsdocMetadata {
|
||||
/**
|
||||
* Whether to generate the tsdoc-metadata.json file.
|
||||
*/
|
||||
enabled: boolean;
|
||||
|
||||
/**
|
||||
* Specifies where the TSDoc metadata file should be written.
|
||||
*
|
||||
* @remarks
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as `<projectFolder>`.
|
||||
*
|
||||
* The default value is `<lookup>`, which causes the path to be automatically inferred from the `tsdocMetadata`,
|
||||
* `typings` or `main` fields of the project's package.json. If none of these fields are set, the lookup
|
||||
* falls back to `tsdoc-metadata.json` in the package folder.
|
||||
*/
|
||||
tsdocMetadataFilePath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures reporting for a given message identifier.
|
||||
*
|
||||
* @remarks
|
||||
* This is part of the {@link IConfigFile} structure.
|
||||
* @public
|
||||
*/
|
||||
export interface IConfigMessageReportingRule {
|
||||
/**
|
||||
* When `addToApiReportFile` is true: If API Extractor is configured to write an API report file (.api.md),
|
||||
* then the message will be written inside that file; otherwise, the message is instead logged according to
|
||||
* the `logLevel` option.
|
||||
*/
|
||||
addToApiReportFile?: boolean;
|
||||
|
||||
/**
|
||||
* Specifies whether the message should be written to the the tool's output log.
|
||||
*
|
||||
* @remarks
|
||||
* Note that the `addToApiReportFile` property may supersede this option.
|
||||
*/
|
||||
logLevel: ExtractorLogLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Specifies a table of reporting rules for different message identifiers, and also the default rule used for
|
||||
* identifiers that do not appear in the table.
|
||||
*
|
||||
* @remarks
|
||||
* This is part of the {@link IConfigFile} structure.
|
||||
* @public
|
||||
*/
|
||||
export interface IConfigMessageReportingTable {
|
||||
/**
|
||||
* The key is a message identifier for the associated type of message, or "default" to specify the default policy.
|
||||
* For example, the key might be `TS2551` (a compiler message), `tsdoc-link-tag-unescaped-text` (a TSDOc message),
|
||||
* or `ae-extra-release-tag` (a message related to the API Extractor analysis).
|
||||
*/
|
||||
[messageId: string]: IConfigMessageReportingRule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures how API Extractor reports error and warning messages produced during analysis.
|
||||
*
|
||||
* @remarks
|
||||
* This is part of the {@link IConfigFile} structure.
|
||||
* @public
|
||||
*/
|
||||
export interface IExtractorMessagesConfig {
|
||||
/**
|
||||
* Configures handling of diagnostic messages generating the TypeScript compiler while analyzing the
|
||||
* input .d.ts files.
|
||||
*/
|
||||
compilerMessageReporting?: IConfigMessageReportingTable;
|
||||
|
||||
/**
|
||||
* Configures handling of messages reported by API Extractor during its analysis.
|
||||
*/
|
||||
extractorMessageReporting?: IConfigMessageReportingTable;
|
||||
|
||||
/**
|
||||
* Configures handling of messages reported by the TSDoc parser when analyzing code comments.
|
||||
*/
|
||||
tsdocMessageReporting?: IConfigMessageReportingTable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration options for the API Extractor tool. These options can be constructed programmatically
|
||||
* or loaded from the api-extractor.json config file using the {@link ExtractorConfig} class.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface IConfigFile {
|
||||
/**
|
||||
* {@inheritDoc IConfigApiReport}
|
||||
*/
|
||||
apiReport?: IConfigApiReport;
|
||||
|
||||
/**
|
||||
* A list of NPM package names whose exports should be treated as part of this package.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* For example, suppose that Webpack is used to generate a distributed bundle for the project `library1`,
|
||||
* and another NPM package `library2` is embedded in this bundle. Some types from `library2` may become part
|
||||
* of the exported API for `library1`, but by default API Extractor would generate a .d.ts rollup that explicitly
|
||||
* imports `library2`. To avoid this, we can specify:
|
||||
*
|
||||
* ```js
|
||||
* "bundledPackages": [ "library2" ],
|
||||
* ```
|
||||
*
|
||||
* This would direct API Extractor to embed those types directly in the .d.ts rollup, as if they had been
|
||||
* local files for `library1`.
|
||||
*/
|
||||
bundledPackages?: string[];
|
||||
|
||||
/**
|
||||
* {@inheritDoc IConfigCompiler}
|
||||
*/
|
||||
compiler?: IConfigCompiler;
|
||||
|
||||
/**
|
||||
* {@inheritDoc IConfigDocModel}
|
||||
*/
|
||||
docModel?: IConfigDocModel;
|
||||
|
||||
/**
|
||||
* {@inheritDoc IConfigDtsRollup}
|
||||
*
|
||||
* @beta
|
||||
*/
|
||||
dtsRollup?: IConfigDtsRollup;
|
||||
|
||||
/**
|
||||
* Specifies how API Extractor sorts members of an enum when generating the .api.json file.
|
||||
*
|
||||
* @remarks
|
||||
* By default, the output files will be sorted alphabetically, which is "by-name".
|
||||
* To keep the ordering in the source code, specify "preserve".
|
||||
* @defaultValue `by-name`
|
||||
*/
|
||||
enumMemberOrder?: EnumMemberOrder;
|
||||
|
||||
/**
|
||||
* Optionally specifies another JSON config file that this file extends from. This provides a way for
|
||||
* standard settings to be shared across multiple projects.
|
||||
*
|
||||
* @remarks
|
||||
* If the path starts with `./` or `../`, the path is resolved relative to the folder of the file that contains
|
||||
* the `extends` field. Otherwise, the first path segment is interpreted as an NPM package name, and will be
|
||||
* resolved using NodeJS `require()`.
|
||||
*/
|
||||
extends?: string;
|
||||
|
||||
/**
|
||||
* Specifies the .d.ts file to be used as the starting point for analysis. API Extractor
|
||||
* analyzes the symbols exported by this module.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* The file extension must be ".d.ts" and not ".ts".
|
||||
* The path is resolved relative to the "projectFolder" location.
|
||||
*/
|
||||
mainEntryPointFilePath: string;
|
||||
|
||||
/**
|
||||
* {@inheritDoc IExtractorMessagesConfig}
|
||||
*/
|
||||
messages?: IExtractorMessagesConfig;
|
||||
|
||||
/**
|
||||
* Specifies what type of newlines API Extractor should use when writing output files.
|
||||
*
|
||||
* @remarks
|
||||
* By default, the output files will be written with Windows-style newlines.
|
||||
* To use POSIX-style newlines, specify "lf" instead.
|
||||
* To use the OS's default newline kind, specify "os".
|
||||
*/
|
||||
newlineKind?: 'crlf' | 'lf' | 'os';
|
||||
|
||||
/**
|
||||
* Determines the `<projectFolder>` token that can be used with other config file settings. The project folder
|
||||
* typically contains the tsconfig.json and package.json config files, but the path is user-defined.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting.
|
||||
*
|
||||
* The default value for `projectFolder` is the token `<lookup>`, which means the folder is determined using
|
||||
* the following heuristics:
|
||||
*
|
||||
* If the config/rig.json system is used (as defined by {@link https://www.npmjs.com/package/@rushstack/rig-package
|
||||
* | @rushstack/rig-package}), then the `<lookup>` value will be the package folder that referenced the rig.
|
||||
*
|
||||
* Otherwise, the `<lookup>` value is determined by traversing parent folders, starting from the folder containing
|
||||
* api-extractor.json, and stopping at the first folder that contains a tsconfig.json file. If a tsconfig.json file
|
||||
* cannot be found in this way, then an error will be reported.
|
||||
*/
|
||||
projectFolder?: string;
|
||||
|
||||
/**
|
||||
* Set to true when invoking API Extractor's test harness.
|
||||
*
|
||||
* @remarks
|
||||
* When `testMode` is true, the `toolVersion` field in the .api.json file is assigned an empty string
|
||||
* to prevent spurious diffs in output files tracked for tests.
|
||||
*/
|
||||
testMode?: boolean;
|
||||
|
||||
/**
|
||||
* {@inheritDoc IConfigTsdocMetadata}
|
||||
*
|
||||
* @beta
|
||||
*/
|
||||
tsdocMetadata?: IConfigTsdocMetadata;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// 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 { StandardTags } from '@microsoft/tsdoc';
|
||||
import { ExtractorConfig } from '../ExtractorConfig.js';
|
||||
|
||||
const testDataFolder: string = path.join(__dirname, 'test-data');
|
||||
|
||||
describe('Extractor-custom-tags', () => {
|
||||
describe('should use a TSDocConfiguration', () => {
|
||||
it.only("with custom TSDoc tags defined in the package's tsdoc.json", () => {
|
||||
const extractorConfig: ExtractorConfig = ExtractorConfig.loadFileAndPrepare(
|
||||
path.join(testDataFolder, 'custom-tsdoc-tags/api-extractor.json'),
|
||||
);
|
||||
const { tsdocConfiguration } = extractorConfig;
|
||||
|
||||
expect(tsdocConfiguration.tryGetTagDefinition('@block')).not.toBe(undefined);
|
||||
expect(tsdocConfiguration.tryGetTagDefinition('@inline')).not.toBe(undefined);
|
||||
expect(tsdocConfiguration.tryGetTagDefinition('@modifier')).not.toBe(undefined);
|
||||
});
|
||||
it.only("with custom TSDoc tags enabled per the package's tsdoc.json", () => {
|
||||
const extractorConfig: ExtractorConfig = ExtractorConfig.loadFileAndPrepare(
|
||||
path.join(testDataFolder, 'custom-tsdoc-tags/api-extractor.json'),
|
||||
);
|
||||
const { tsdocConfiguration } = extractorConfig;
|
||||
const block = tsdocConfiguration.tryGetTagDefinition('@block')!;
|
||||
const inline = tsdocConfiguration.tryGetTagDefinition('@inline')!;
|
||||
const modifier = tsdocConfiguration.tryGetTagDefinition('@modifier')!;
|
||||
|
||||
expect(tsdocConfiguration.isTagSupported(block)).toBe(true);
|
||||
expect(tsdocConfiguration.isTagSupported(inline)).toBe(true);
|
||||
expect(tsdocConfiguration.isTagSupported(modifier)).toBe(false);
|
||||
});
|
||||
it.only("with standard tags and API Extractor custom tags defined and supported when the package's tsdoc.json extends API Extractor's tsdoc.json", () => {
|
||||
const extractorConfig: ExtractorConfig = ExtractorConfig.loadFileAndPrepare(
|
||||
path.join(testDataFolder, 'custom-tsdoc-tags/api-extractor.json'),
|
||||
);
|
||||
const { tsdocConfiguration } = extractorConfig;
|
||||
|
||||
expect(tsdocConfiguration.tryGetTagDefinition('@inline')).not.toBe(undefined);
|
||||
expect(tsdocConfiguration.tryGetTagDefinition('@block')).not.toBe(undefined);
|
||||
expect(tsdocConfiguration.tryGetTagDefinition('@modifier')).not.toBe(undefined);
|
||||
|
||||
for (const tag of StandardTags.allDefinitions.concat([
|
||||
tsdocConfiguration.tryGetTagDefinition('@betaDocumentation')!,
|
||||
tsdocConfiguration.tryGetTagDefinition('@internalRemarks')!,
|
||||
tsdocConfiguration.tryGetTagDefinition('@preapproved')!,
|
||||
])) {
|
||||
expect(tsdocConfiguration.tagDefinitions.includes(tag));
|
||||
expect(tsdocConfiguration.supportedTagDefinitions.includes(tag));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
// 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 } from '@rushstack/node-core-library';
|
||||
import { ExtractorConfig } from '../ExtractorConfig.js';
|
||||
|
||||
const testDataFolder: string = path.join(__dirname, 'test-data');
|
||||
|
||||
function expectEqualPaths(path1: string, path2: string): void {
|
||||
if (!Path.isEqual(path1, path2)) {
|
||||
fail('Expected paths to be equal:\npath1: ' + path1 + '\npath2: ' + path2);
|
||||
}
|
||||
}
|
||||
|
||||
// Tests for expanding the "<lookup>" token for the "projectFolder" setting in api-extractor.json
|
||||
describe(`${ExtractorConfig.name}.${ExtractorConfig.loadFileAndPrepare.name}`, () => {
|
||||
it.only('config-lookup1: looks up ./api-extractor.json', () => {
|
||||
const extractorConfig: ExtractorConfig = ExtractorConfig.loadFileAndPrepare(
|
||||
path.join(testDataFolder, 'config-lookup1/api-extractor.json'),
|
||||
);
|
||||
expectEqualPaths(extractorConfig.projectFolder, path.join(testDataFolder, 'config-lookup1'));
|
||||
});
|
||||
it.only('config-lookup2: looks up ./config/api-extractor.json', () => {
|
||||
const extractorConfig: ExtractorConfig = ExtractorConfig.loadFileAndPrepare(
|
||||
path.join(testDataFolder, 'config-lookup2/config/api-extractor.json'),
|
||||
);
|
||||
expectEqualPaths(extractorConfig.projectFolder, path.join(testDataFolder, 'config-lookup2'));
|
||||
});
|
||||
it.only('config-lookup3a: looks up ./src/test/config/api-extractor.json', () => {
|
||||
const extractorConfig: ExtractorConfig = ExtractorConfig.loadFileAndPrepare(
|
||||
path.join(testDataFolder, 'config-lookup3/src/test/config/api-extractor.json'),
|
||||
);
|
||||
expectEqualPaths(extractorConfig.projectFolder, path.join(testDataFolder, 'config-lookup3/src/test/'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
|
||||
|
||||
"mainEntryPointFilePath": "<projectFolder>/index.d.ts",
|
||||
|
||||
"apiReport": {
|
||||
"enabled": true
|
||||
},
|
||||
|
||||
"docModel": {
|
||||
"enabled": true
|
||||
},
|
||||
|
||||
"dtsRollup": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
2
packages/api-extractor/src/api/test/test-data/config-lookup1/index.d.ts
vendored
Normal file
2
packages/api-extractor/src/api/test/test-data/config-lookup1/index.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/* eslint-disable unicorn/no-empty-file */
|
||||
// empty file
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "config-lookup1",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/tsconfig",
|
||||
|
||||
"compilerOptions": {
|
||||
"outDir": "lib",
|
||||
"rootDir": "src",
|
||||
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"jsx": "react",
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"experimentalDecorators": true,
|
||||
"strictNullChecks": true,
|
||||
"noUnusedLocals": true,
|
||||
"types": ["heft-jest", "node"],
|
||||
|
||||
"module": "commonjs",
|
||||
"target": "es2017",
|
||||
"lib": ["es2017"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["node_modules", "lib"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
|
||||
|
||||
"mainEntryPointFilePath": "<projectFolder>/index.d.ts",
|
||||
|
||||
"apiReport": {
|
||||
"enabled": true
|
||||
},
|
||||
|
||||
"docModel": {
|
||||
"enabled": true
|
||||
},
|
||||
|
||||
"dtsRollup": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
2
packages/api-extractor/src/api/test/test-data/config-lookup2/index.d.ts
vendored
Normal file
2
packages/api-extractor/src/api/test/test-data/config-lookup2/index.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/* eslint-disable unicorn/no-empty-file */
|
||||
// empty file
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "config-lookup2",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/tsconfig",
|
||||
|
||||
"compilerOptions": {
|
||||
"outDir": "lib",
|
||||
"rootDir": "src",
|
||||
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"jsx": "react",
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"experimentalDecorators": true,
|
||||
"strictNullChecks": true,
|
||||
"noUnusedLocals": true,
|
||||
"types": ["heft-jest", "node"],
|
||||
|
||||
"module": "commonjs",
|
||||
"target": "es2017",
|
||||
"lib": ["es2017"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["node_modules", "lib"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
|
||||
|
||||
"mainEntryPointFilePath": "<projectFolder>/index.d.ts",
|
||||
|
||||
"apiReport": {
|
||||
"enabled": true
|
||||
},
|
||||
|
||||
"docModel": {
|
||||
"enabled": true
|
||||
},
|
||||
|
||||
"dtsRollup": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
2
packages/api-extractor/src/api/test/test-data/config-lookup3/index.d.ts
vendored
Normal file
2
packages/api-extractor/src/api/test/test-data/config-lookup3/index.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/* eslint-disable unicorn/no-empty-file */
|
||||
// empty file
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "config-lookup3",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
|
||||
|
||||
"mainEntryPointFilePath": "<projectFolder>/index.d.ts",
|
||||
|
||||
"apiReport": {
|
||||
"enabled": true
|
||||
},
|
||||
|
||||
"docModel": {
|
||||
"enabled": true
|
||||
},
|
||||
|
||||
"dtsRollup": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
2
packages/api-extractor/src/api/test/test-data/config-lookup3/src/test/index.d.ts
vendored
Normal file
2
packages/api-extractor/src/api/test/test-data/config-lookup3/src/test/index.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/* eslint-disable unicorn/no-empty-file */
|
||||
// empty file
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/tsconfig",
|
||||
|
||||
"compilerOptions": {
|
||||
"outDir": "lib",
|
||||
"rootDir": "src",
|
||||
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"jsx": "react",
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"experimentalDecorators": true,
|
||||
"strictNullChecks": true,
|
||||
"noUnusedLocals": true,
|
||||
"types": ["heft-jest", "node"],
|
||||
|
||||
"module": "commonjs",
|
||||
"target": "es2017",
|
||||
"lib": ["es2017"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["node_modules", "lib"]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/tsconfig",
|
||||
|
||||
"compilerOptions": {
|
||||
"outDir": "lib",
|
||||
"rootDir": "src",
|
||||
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"jsx": "react",
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"experimentalDecorators": true,
|
||||
"strictNullChecks": true,
|
||||
"noUnusedLocals": true,
|
||||
"types": ["heft-jest", "node"],
|
||||
|
||||
"module": "commonjs",
|
||||
"target": "es2017",
|
||||
"lib": ["es2017"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["node_modules", "lib"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
|
||||
|
||||
"mainEntryPointFilePath": "<projectFolder>/index.d.ts",
|
||||
|
||||
"apiReport": {
|
||||
"enabled": true
|
||||
},
|
||||
|
||||
"docModel": {
|
||||
"enabled": true
|
||||
},
|
||||
|
||||
"dtsRollup": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
7
packages/api-extractor/src/api/test/test-data/custom-tsdoc-tags/index.d.ts
vendored
Normal file
7
packages/api-extractor/src/api/test/test-data/custom-tsdoc-tags/index.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/no-empty-interface,tsdoc/syntax */
|
||||
/**
|
||||
* @block
|
||||
* @inline test
|
||||
* @modifier
|
||||
*/
|
||||
interface CustomTagsTestInterface {}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "config-lookup1",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/tsconfig",
|
||||
|
||||
"compilerOptions": {
|
||||
"outDir": "lib",
|
||||
"rootDir": "src",
|
||||
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"jsx": "react",
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"experimentalDecorators": true,
|
||||
"strictNullChecks": true,
|
||||
"noUnusedLocals": true,
|
||||
"types": ["heft-jest", "node"],
|
||||
|
||||
"module": "commonjs",
|
||||
"target": "es2017",
|
||||
"lib": ["es2017"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["node_modules", "lib"]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json",
|
||||
"extends": ["../../../../../extends/tsdoc-base.json"],
|
||||
"tagDefinitions": [
|
||||
{
|
||||
"tagName": "@block",
|
||||
"syntaxKind": "block"
|
||||
},
|
||||
{
|
||||
"tagName": "@inline",
|
||||
"syntaxKind": "inline",
|
||||
"allowMultiple": true
|
||||
},
|
||||
{
|
||||
"tagName": "@modifier",
|
||||
"syntaxKind": "modifier"
|
||||
}
|
||||
],
|
||||
"supportForTags": {
|
||||
"@block": true,
|
||||
"@inline": true,
|
||||
"@modifier": false
|
||||
}
|
||||
}
|
||||
57
packages/api-extractor/src/cli/ApiExtractorCommandLine.ts
Normal file
57
packages/api-extractor/src/cli/ApiExtractorCommandLine.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
import * as os from 'node:os';
|
||||
import { InternalError } from '@rushstack/node-core-library';
|
||||
import { CommandLineParser, type CommandLineFlagParameter } from '@rushstack/ts-command-line';
|
||||
import colors from 'colors';
|
||||
import { InitAction } from './InitAction.js';
|
||||
import { RunAction } from './RunAction.js';
|
||||
|
||||
export class ApiExtractorCommandLine extends CommandLineParser {
|
||||
private readonly _debugParameter: CommandLineFlagParameter;
|
||||
|
||||
public constructor() {
|
||||
super({
|
||||
toolFilename: 'api-extractor',
|
||||
toolDescription:
|
||||
'API Extractor helps you build better TypeScript libraries. It analyzes the main entry' +
|
||||
' point for your package, collects the inventory of exported declarations, and then generates three kinds' +
|
||||
' of output: an API report file (.api.md) to facilitate reviews, a declaration rollup (.d.ts) to be' +
|
||||
' published with your NPM package, and a doc model file (.api.json) to be used with a documentation' +
|
||||
' tool such as api-documenter. For details, please visit the web site.',
|
||||
});
|
||||
this._populateActions();
|
||||
|
||||
this._debugParameter = this.defineFlagParameter({
|
||||
parameterLongName: '--debug',
|
||||
parameterShortName: '-d',
|
||||
description: 'Show the full call stack if an error occurs while executing the tool',
|
||||
});
|
||||
}
|
||||
|
||||
protected override async onExecute(): Promise<void> {
|
||||
// override
|
||||
if (this._debugParameter.value) {
|
||||
InternalError.breakInDebugger = true;
|
||||
}
|
||||
|
||||
try {
|
||||
await super.onExecute();
|
||||
} catch (error: any) {
|
||||
if (this._debugParameter.value) {
|
||||
console.error(os.EOL + error.stack);
|
||||
} else {
|
||||
console.error(os.EOL + colors.red('ERROR: ' + error.message.trim()));
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals, n/prefer-global/process
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
private _populateActions(): void {
|
||||
this.addAction(new InitAction(this));
|
||||
this.addAction(new RunAction(this));
|
||||
}
|
||||
}
|
||||
45
packages/api-extractor/src/cli/InitAction.ts
Normal file
45
packages/api-extractor/src/cli/InitAction.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// 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 } from '@rushstack/node-core-library';
|
||||
import { CommandLineAction } from '@rushstack/ts-command-line';
|
||||
import colors from 'colors';
|
||||
import { ExtractorConfig } from '../api/ExtractorConfig.js';
|
||||
import type { ApiExtractorCommandLine } from './ApiExtractorCommandLine.js';
|
||||
|
||||
export class InitAction extends CommandLineAction {
|
||||
public constructor(_parser: ApiExtractorCommandLine) {
|
||||
super({
|
||||
actionName: 'init',
|
||||
summary: `Create an ${ExtractorConfig.FILENAME} config file`,
|
||||
documentation:
|
||||
`Use this command when setting up API Extractor for a new project. It writes an` +
|
||||
` ${ExtractorConfig.FILENAME} config file template with code comments that describe all the settings.` +
|
||||
` The file will be written in the current directory.`,
|
||||
});
|
||||
}
|
||||
|
||||
protected async onExecute(): Promise<void> {
|
||||
// override
|
||||
const inputFilePath: string = path.resolve(__dirname, './schemas/api-extractor-template.json');
|
||||
const outputFilePath: string = path.resolve(ExtractorConfig.FILENAME);
|
||||
|
||||
if (FileSystem.exists(outputFilePath)) {
|
||||
console.log(colors.red('The output file already exists:'));
|
||||
console.log('\n ' + outputFilePath + '\n');
|
||||
throw new Error('Unable to write output file');
|
||||
}
|
||||
|
||||
console.log(colors.green('Writing file: ') + outputFilePath);
|
||||
FileSystem.copyFile({
|
||||
sourcePath: inputFilePath,
|
||||
destinationPath: outputFilePath,
|
||||
});
|
||||
|
||||
console.log(
|
||||
'\nThe recommended location for this file is in the project\'s "config" subfolder,\n' +
|
||||
'or else in the top-level folder with package.json.',
|
||||
);
|
||||
}
|
||||
}
|
||||
156
packages/api-extractor/src/cli/RunAction.ts
Normal file
156
packages/api-extractor/src/cli/RunAction.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
/* eslint-disable n/prefer-global/process */
|
||||
/* eslint-disable no-restricted-globals */
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { PackageJsonLookup, FileSystem, type IPackageJson, Path } from '@rushstack/node-core-library';
|
||||
import {
|
||||
CommandLineAction,
|
||||
type CommandLineStringParameter,
|
||||
type CommandLineFlagParameter,
|
||||
} from '@rushstack/ts-command-line';
|
||||
import colors from 'colors';
|
||||
import { Extractor, type ExtractorResult } from '../api/Extractor.js';
|
||||
import { ExtractorConfig, type IExtractorConfigPrepareOptions } from '../api/ExtractorConfig.js';
|
||||
import type { ApiExtractorCommandLine } from './ApiExtractorCommandLine.js';
|
||||
|
||||
export class RunAction extends CommandLineAction {
|
||||
private readonly _configFileParameter: CommandLineStringParameter;
|
||||
|
||||
private readonly _localParameter: CommandLineFlagParameter;
|
||||
|
||||
private readonly _verboseParameter: CommandLineFlagParameter;
|
||||
|
||||
private readonly _diagnosticsParameter: CommandLineFlagParameter;
|
||||
|
||||
private readonly _typescriptCompilerFolder: CommandLineStringParameter;
|
||||
|
||||
public constructor(_parser: ApiExtractorCommandLine) {
|
||||
super({
|
||||
actionName: 'run',
|
||||
summary: 'Invoke API Extractor on a project',
|
||||
documentation: 'Invoke API Extractor on a project',
|
||||
});
|
||||
|
||||
this._configFileParameter = this.defineStringParameter({
|
||||
parameterLongName: '--config',
|
||||
parameterShortName: '-c',
|
||||
argumentName: 'FILE',
|
||||
description: `Use the specified ${ExtractorConfig.FILENAME} file path, rather than guessing its location`,
|
||||
});
|
||||
|
||||
this._localParameter = this.defineFlagParameter({
|
||||
parameterLongName: '--local',
|
||||
parameterShortName: '-l',
|
||||
description:
|
||||
'Indicates that API Extractor is running as part of a local build,' +
|
||||
" e.g. on a developer's machine. This disables certain validation that would" +
|
||||
' normally be performed for a ship/production build. For example, the *.api.md' +
|
||||
' report file is automatically copied in a local build.',
|
||||
});
|
||||
|
||||
this._verboseParameter = this.defineFlagParameter({
|
||||
parameterLongName: '--verbose',
|
||||
parameterShortName: '-v',
|
||||
description: 'Show additional informational messages in the output.',
|
||||
});
|
||||
|
||||
this._diagnosticsParameter = this.defineFlagParameter({
|
||||
parameterLongName: '--diagnostics',
|
||||
description:
|
||||
'Show diagnostic messages used for troubleshooting problems with API Extractor.' +
|
||||
' This flag also enables the "--verbose" flag.',
|
||||
});
|
||||
|
||||
this._typescriptCompilerFolder = this.defineStringParameter({
|
||||
parameterLongName: '--typescript-compiler-folder',
|
||||
argumentName: 'PATH',
|
||||
description:
|
||||
'API Extractor uses its own TypeScript compiler engine to analyze your project. If your project' +
|
||||
' is built with a significantly different TypeScript version, sometimes API Extractor may report compilation' +
|
||||
' errors due to differences in the system typings (e.g. lib.dom.d.ts). You can use the' +
|
||||
' "--typescriptCompilerFolder" option to specify the folder path where you installed the TypeScript package,' +
|
||||
" and API Extractor's compiler will use those system typings instead.",
|
||||
});
|
||||
}
|
||||
|
||||
protected async onExecute(): Promise<void> {
|
||||
// override
|
||||
const lookup: PackageJsonLookup = new PackageJsonLookup();
|
||||
let configFilename: string;
|
||||
|
||||
let typescriptCompilerFolder: string | undefined = this._typescriptCompilerFolder.value;
|
||||
if (typescriptCompilerFolder) {
|
||||
typescriptCompilerFolder = path.normalize(typescriptCompilerFolder);
|
||||
|
||||
if (FileSystem.exists(typescriptCompilerFolder)) {
|
||||
typescriptCompilerFolder = lookup.tryGetPackageFolderFor(typescriptCompilerFolder);
|
||||
const typescriptCompilerPackageJson: IPackageJson | undefined = typescriptCompilerFolder
|
||||
? lookup.tryLoadPackageJsonFor(typescriptCompilerFolder)
|
||||
: undefined;
|
||||
if (!typescriptCompilerPackageJson) {
|
||||
throw new Error(
|
||||
`The path specified in the ${this._typescriptCompilerFolder.longName} parameter is not a package.`,
|
||||
);
|
||||
} else if (typescriptCompilerPackageJson.name !== 'typescript') {
|
||||
throw new Error(
|
||||
`The path specified in the ${this._typescriptCompilerFolder.longName} parameter is not a TypeScript` +
|
||||
' compiler package.',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
`The path specified in the ${this._typescriptCompilerFolder.longName} parameter does not exist.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let extractorConfig: ExtractorConfig;
|
||||
|
||||
if (this._configFileParameter.value) {
|
||||
configFilename = path.normalize(this._configFileParameter.value);
|
||||
if (!FileSystem.exists(configFilename)) {
|
||||
throw new Error('Config file not found: ' + this._configFileParameter.value);
|
||||
}
|
||||
|
||||
extractorConfig = ExtractorConfig.loadFileAndPrepare(configFilename);
|
||||
} else {
|
||||
const prepareOptions: IExtractorConfigPrepareOptions | undefined = ExtractorConfig.tryLoadForFolder({
|
||||
startingFolder: '.',
|
||||
});
|
||||
|
||||
if (!prepareOptions?.configObjectFullPath) {
|
||||
throw new Error(`Unable to find an ${ExtractorConfig.FILENAME} file`);
|
||||
}
|
||||
|
||||
const configObjectShortPath: string = Path.formatConcisely({
|
||||
pathToConvert: prepareOptions.configObjectFullPath,
|
||||
baseFolder: process.cwd(),
|
||||
});
|
||||
console.log(`Using configuration from ${configObjectShortPath}`);
|
||||
|
||||
extractorConfig = ExtractorConfig.prepare(prepareOptions);
|
||||
}
|
||||
|
||||
const extractorResult: ExtractorResult = Extractor.invoke(extractorConfig, {
|
||||
localBuild: this._localParameter.value,
|
||||
showVerboseMessages: this._verboseParameter.value,
|
||||
showDiagnostics: this._diagnosticsParameter.value,
|
||||
typescriptCompilerFolder,
|
||||
});
|
||||
|
||||
if (extractorResult.succeeded) {
|
||||
console.log(os.EOL + 'API Extractor completed successfully');
|
||||
} else {
|
||||
process.exitCode = 1;
|
||||
|
||||
if (extractorResult.errorCount > 0) {
|
||||
console.log(os.EOL + colors.red('API Extractor completed with errors'));
|
||||
} else {
|
||||
console.log(os.EOL + colors.yellow('API Extractor completed with warnings'));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
96
packages/api-extractor/src/collector/ApiItemMetadata.ts
Normal file
96
packages/api-extractor/src/collector/ApiItemMetadata.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
import type { ReleaseTag } from '@discordjs/api-extractor-model';
|
||||
import type * as tsdoc from '@microsoft/tsdoc';
|
||||
import { VisitorState } from './VisitorState.js';
|
||||
|
||||
/**
|
||||
* Constructor parameters for `ApiItemMetadata`.
|
||||
*/
|
||||
export interface IApiItemMetadataOptions {
|
||||
declaredReleaseTag: ReleaseTag;
|
||||
effectiveReleaseTag: ReleaseTag;
|
||||
isEventProperty: boolean;
|
||||
isOverride: boolean;
|
||||
isPreapproved: boolean;
|
||||
isSealed: boolean;
|
||||
isVirtual: boolean;
|
||||
releaseTagSameAsParent: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the Collector's additional analysis for an `AstDeclaration`. This object is assigned to
|
||||
* `AstDeclaration.apiItemMetadata` but consumers must always obtain it by calling `Collector.fetchApiItemMetadata()`.
|
||||
*
|
||||
* @remarks
|
||||
* Note that ancillary declarations share their `ApiItemMetadata` with the main declaration,
|
||||
* whereas a separate `DeclarationMetadata` object is created for each declaration.
|
||||
*
|
||||
* Consider this example:
|
||||
* ```ts
|
||||
* export declare class A {
|
||||
* get b(): string;
|
||||
* set b(value: string);
|
||||
* }
|
||||
* export declare namespace A { }
|
||||
* ```
|
||||
*
|
||||
* In this example, there are two "symbols": `A` and `b`
|
||||
*
|
||||
* There are four "declarations": `A` class, `A` namespace, `b` getter, `b` setter
|
||||
*
|
||||
* There are three "API items": `A` class, `A` namespace, `b` property. The property getter is the main declaration
|
||||
* for `b`, and the setter is the "ancillary" declaration.
|
||||
*/
|
||||
export class ApiItemMetadata {
|
||||
/**
|
||||
* This is the release tag that was explicitly specified in the original doc comment, if any.
|
||||
*/
|
||||
public readonly declaredReleaseTag: ReleaseTag;
|
||||
|
||||
/**
|
||||
* The "effective" release tag is a normalized value that is based on `declaredReleaseTag`,
|
||||
* but may be inherited from a parent, or corrected if the declared value was somehow invalid.
|
||||
* When actually trimming .d.ts files or generating docs, API Extractor uses the "effective" value
|
||||
* instead of the "declared" value.
|
||||
*/
|
||||
public readonly effectiveReleaseTag: ReleaseTag;
|
||||
|
||||
// If true, then it would be redundant to show this release tag
|
||||
public readonly releaseTagSameAsParent: boolean;
|
||||
|
||||
// NOTE: In the future, the Collector may infer or error-correct some of these states.
|
||||
// Generators should rely on these instead of tsdocComment.modifierTagSet.
|
||||
public readonly isEventProperty: boolean;
|
||||
|
||||
public readonly isOverride: boolean;
|
||||
|
||||
public readonly isSealed: boolean;
|
||||
|
||||
public readonly isVirtual: boolean;
|
||||
|
||||
public readonly isPreapproved: boolean;
|
||||
|
||||
/**
|
||||
* This is the TSDoc comment for the declaration. It may be modified (or constructed artificially) by
|
||||
* the DocCommentEnhancer.
|
||||
*/
|
||||
public tsdocComment: tsdoc.DocComment | undefined;
|
||||
|
||||
// Assigned by DocCommentEnhancer
|
||||
public undocumented: boolean = true;
|
||||
|
||||
public docCommentEnhancerVisitorState: VisitorState = VisitorState.Unvisited;
|
||||
|
||||
public constructor(options: IApiItemMetadataOptions) {
|
||||
this.declaredReleaseTag = options.declaredReleaseTag;
|
||||
this.effectiveReleaseTag = options.effectiveReleaseTag;
|
||||
this.releaseTagSameAsParent = options.releaseTagSameAsParent;
|
||||
this.isEventProperty = options.isEventProperty;
|
||||
this.isOverride = options.isOverride;
|
||||
this.isSealed = options.isSealed;
|
||||
this.isVirtual = options.isVirtual;
|
||||
this.isPreapproved = options.isPreapproved;
|
||||
}
|
||||
}
|
||||
953
packages/api-extractor/src/collector/Collector.ts
Normal file
953
packages/api-extractor/src/collector/Collector.ts
Normal file
@@ -0,0 +1,953 @@
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
import { ReleaseTag } from '@discordjs/api-extractor-model';
|
||||
import * as tsdoc from '@microsoft/tsdoc';
|
||||
import { PackageJsonLookup, Sort, InternalError } from '@rushstack/node-core-library';
|
||||
import * as ts from 'typescript';
|
||||
import { PackageDocComment } from '../aedoc/PackageDocComment.js';
|
||||
import type { AstDeclaration } from '../analyzer/AstDeclaration.js';
|
||||
import type { AstEntity } from '../analyzer/AstEntity.js';
|
||||
import { AstImport } from '../analyzer/AstImport.js';
|
||||
import type { AstModule, AstModuleExportInfo } from '../analyzer/AstModule.js';
|
||||
import { AstNamespaceImport } from '../analyzer/AstNamespaceImport.js';
|
||||
import { AstReferenceResolver } from '../analyzer/AstReferenceResolver.js';
|
||||
import { AstSymbol } from '../analyzer/AstSymbol.js';
|
||||
import { AstSymbolTable } from '../analyzer/AstSymbolTable.js';
|
||||
import { TypeScriptHelpers } from '../analyzer/TypeScriptHelpers.js';
|
||||
import { TypeScriptInternals, type IGlobalVariableAnalyzer } from '../analyzer/TypeScriptInternals.js';
|
||||
import { ExtractorConfig } from '../api/ExtractorConfig.js';
|
||||
import { ExtractorMessageId } from '../api/ExtractorMessageId.js';
|
||||
import { ApiItemMetadata, type IApiItemMetadataOptions } from './ApiItemMetadata.js';
|
||||
import { CollectorEntity } from './CollectorEntity.js';
|
||||
import { type DeclarationMetadata, InternalDeclarationMetadata } from './DeclarationMetadata.js';
|
||||
import type { MessageRouter } from './MessageRouter.js';
|
||||
import type { SourceMapper } from './SourceMapper.js';
|
||||
import { SymbolMetadata } from './SymbolMetadata.js';
|
||||
import { WorkingPackage } from './WorkingPackage.js';
|
||||
|
||||
/**
|
||||
* Options for Collector constructor.
|
||||
*/
|
||||
export interface ICollectorOptions {
|
||||
extractorConfig: ExtractorConfig;
|
||||
|
||||
messageRouter: MessageRouter;
|
||||
|
||||
/**
|
||||
* Configuration for the TypeScript compiler. The most important options to set are:
|
||||
*
|
||||
* - target: ts.ScriptTarget.ES5
|
||||
* - module: ts.ModuleKind.CommonJS
|
||||
* - moduleResolution: ts.ModuleResolutionKind.NodeJs
|
||||
* - rootDir: inputFolder
|
||||
*/
|
||||
program: ts.Program;
|
||||
|
||||
sourceMapper: SourceMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Collector` manages the overall data set that is used by `ApiModelGenerator`,
|
||||
* `DtsRollupGenerator`, and `ApiReportGenerator`. Starting from the working package's entry point,
|
||||
* the `Collector` collects all exported symbols, determines how to import any symbols they reference,
|
||||
* assigns unique names, and sorts everything into a normalized alphabetical ordering.
|
||||
*/
|
||||
export class Collector {
|
||||
public readonly program: ts.Program;
|
||||
|
||||
public readonly typeChecker: ts.TypeChecker;
|
||||
|
||||
public readonly globalVariableAnalyzer: IGlobalVariableAnalyzer;
|
||||
|
||||
public readonly astSymbolTable: AstSymbolTable;
|
||||
|
||||
public readonly astReferenceResolver: AstReferenceResolver;
|
||||
|
||||
public readonly packageJsonLookup: PackageJsonLookup;
|
||||
|
||||
public readonly messageRouter: MessageRouter;
|
||||
|
||||
public readonly workingPackage: WorkingPackage;
|
||||
|
||||
public readonly extractorConfig: ExtractorConfig;
|
||||
|
||||
public readonly sourceMapper: SourceMapper;
|
||||
|
||||
/**
|
||||
* The `ExtractorConfig.bundledPackages` names in a set.
|
||||
*/
|
||||
public readonly bundledPackageNames: ReadonlySet<string>;
|
||||
|
||||
private readonly _program: ts.Program;
|
||||
|
||||
private readonly _tsdocParser: tsdoc.TSDocParser;
|
||||
|
||||
private _astEntryPoint: AstModule | undefined;
|
||||
|
||||
private readonly _entities: CollectorEntity[] = [];
|
||||
|
||||
private readonly _entitiesByAstEntity: Map<AstEntity, CollectorEntity> = new Map<AstEntity, CollectorEntity>();
|
||||
|
||||
private readonly _entitiesBySymbol: Map<ts.Symbol, CollectorEntity> = new Map<ts.Symbol, CollectorEntity>();
|
||||
|
||||
private readonly _starExportedExternalModulePaths: string[] = [];
|
||||
|
||||
private readonly _dtsTypeReferenceDirectives: Set<string> = new Set<string>();
|
||||
|
||||
private readonly _dtsLibReferenceDirectives: Set<string> = new Set<string>();
|
||||
|
||||
// Used by getOverloadIndex()
|
||||
private readonly _cachedOverloadIndexesByDeclaration: Map<AstDeclaration, number>;
|
||||
|
||||
public constructor(options: ICollectorOptions) {
|
||||
this.packageJsonLookup = new PackageJsonLookup();
|
||||
|
||||
this._program = options.program;
|
||||
this.extractorConfig = options.extractorConfig;
|
||||
this.sourceMapper = options.sourceMapper;
|
||||
|
||||
const entryPointSourceFile: ts.SourceFile | undefined = options.program.getSourceFile(
|
||||
this.extractorConfig.mainEntryPointFilePath,
|
||||
);
|
||||
|
||||
if (!entryPointSourceFile) {
|
||||
throw new Error('Unable to load file: ' + this.extractorConfig.mainEntryPointFilePath);
|
||||
}
|
||||
|
||||
if (!this.extractorConfig.packageFolder || !this.extractorConfig.packageJson) {
|
||||
throw new Error('Unable to find a package.json file for the project being analyzed');
|
||||
}
|
||||
|
||||
this.workingPackage = new WorkingPackage({
|
||||
packageFolder: this.extractorConfig.packageFolder,
|
||||
packageJson: this.extractorConfig.packageJson,
|
||||
entryPointSourceFile,
|
||||
});
|
||||
|
||||
this.messageRouter = options.messageRouter;
|
||||
|
||||
this.program = options.program;
|
||||
this.typeChecker = options.program.getTypeChecker();
|
||||
this.globalVariableAnalyzer = TypeScriptInternals.getGlobalVariableAnalyzer(this.program);
|
||||
|
||||
this._tsdocParser = new tsdoc.TSDocParser(this.extractorConfig.tsdocConfiguration);
|
||||
|
||||
this.bundledPackageNames = new Set<string>(this.extractorConfig.bundledPackages);
|
||||
|
||||
this.astSymbolTable = new AstSymbolTable(
|
||||
this.program,
|
||||
this.typeChecker,
|
||||
this.packageJsonLookup,
|
||||
this.bundledPackageNames,
|
||||
this.messageRouter,
|
||||
);
|
||||
this.astReferenceResolver = new AstReferenceResolver(this);
|
||||
|
||||
this._cachedOverloadIndexesByDeclaration = new Map<AstDeclaration, number>();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of names (e.g. "example-library") that should appear in a reference like this:
|
||||
*
|
||||
* ```
|
||||
* /// <reference types="example-library" />
|
||||
* ```
|
||||
*/
|
||||
public get dtsTypeReferenceDirectives(): ReadonlySet<string> {
|
||||
return this._dtsTypeReferenceDirectives;
|
||||
}
|
||||
|
||||
/**
|
||||
* A list of names (e.g. "runtime-library") that should appear in a reference like this:
|
||||
*
|
||||
* ```
|
||||
* /// <reference lib="runtime-library" />
|
||||
* ```
|
||||
*/
|
||||
public get dtsLibReferenceDirectives(): ReadonlySet<string> {
|
||||
return this._dtsLibReferenceDirectives;
|
||||
}
|
||||
|
||||
public get entities(): readonly CollectorEntity[] {
|
||||
return this._entities;
|
||||
}
|
||||
|
||||
/**
|
||||
* A list of module specifiers (e.g. `"@rushstack/node-core-library/lib/FileSystem"`) that should be emitted
|
||||
* as star exports (e.g. `export * from "@rushstack/node-core-library/lib/FileSystem"`).
|
||||
*/
|
||||
public get starExportedExternalModulePaths(): readonly string[] {
|
||||
return this._starExportedExternalModulePaths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the analysis.
|
||||
*/
|
||||
public analyze(): void {
|
||||
if (this._astEntryPoint) {
|
||||
throw new Error('DtsRollupGenerator.analyze() was already called');
|
||||
}
|
||||
|
||||
// This runs a full type analysis, and then augments the Abstract Syntax Tree (i.e. declarations)
|
||||
// with semantic information (i.e. symbols). The "diagnostics" are a subset of the everyday
|
||||
// compile errors that would result from a full compilation.
|
||||
for (const diagnostic of this._program.getSemanticDiagnostics()) {
|
||||
this.messageRouter.addCompilerDiagnostic(diagnostic);
|
||||
}
|
||||
|
||||
const sourceFiles: readonly ts.SourceFile[] = this.program.getSourceFiles();
|
||||
|
||||
if (this.messageRouter.showDiagnostics) {
|
||||
this.messageRouter.logDiagnosticHeader('Root filenames');
|
||||
for (const fileName of this.program.getRootFileNames()) {
|
||||
this.messageRouter.logDiagnostic(fileName);
|
||||
}
|
||||
|
||||
this.messageRouter.logDiagnosticFooter();
|
||||
|
||||
this.messageRouter.logDiagnosticHeader('Files analyzed by compiler');
|
||||
for (const sourceFile of sourceFiles) {
|
||||
this.messageRouter.logDiagnostic(sourceFile.fileName);
|
||||
}
|
||||
|
||||
this.messageRouter.logDiagnosticFooter();
|
||||
}
|
||||
|
||||
// We can throw this error earlier in CompilerState.ts, but intentionally wait until after we've logged the
|
||||
// associated diagnostic message above to make debugging easier for developers.
|
||||
// Typically there will be many such files -- to avoid too much noise, only report the first one.
|
||||
const badSourceFile: ts.SourceFile | undefined = sourceFiles.find(
|
||||
({ fileName }) => !ExtractorConfig.hasDtsFileExtension(fileName),
|
||||
);
|
||||
if (badSourceFile) {
|
||||
this.messageRouter.addAnalyzerIssueForPosition(
|
||||
ExtractorMessageId.WrongInputFileType,
|
||||
'Incorrect file type; API Extractor expects to analyze compiler outputs with the .d.ts file extension. ' +
|
||||
'Troubleshooting tips: https://api-extractor.com/link/dts-error',
|
||||
badSourceFile,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
// Build the entry point
|
||||
const entryPointSourceFile: ts.SourceFile = this.workingPackage.entryPointSourceFile;
|
||||
|
||||
const astEntryPoint: AstModule = this.astSymbolTable.fetchAstModuleFromWorkingPackage(entryPointSourceFile);
|
||||
this._astEntryPoint = astEntryPoint;
|
||||
|
||||
const packageDocCommentTextRange: ts.TextRange | undefined = PackageDocComment.tryFindInSourceFile(
|
||||
entryPointSourceFile,
|
||||
this,
|
||||
);
|
||||
|
||||
if (packageDocCommentTextRange) {
|
||||
const range: tsdoc.TextRange = tsdoc.TextRange.fromStringRange(
|
||||
entryPointSourceFile.text,
|
||||
packageDocCommentTextRange.pos,
|
||||
packageDocCommentTextRange.end,
|
||||
);
|
||||
|
||||
this.workingPackage.tsdocParserContext = this._tsdocParser.parseRange(range);
|
||||
|
||||
this.messageRouter.addTsdocMessages(this.workingPackage.tsdocParserContext, entryPointSourceFile);
|
||||
|
||||
this.workingPackage.tsdocComment = this.workingPackage.tsdocParserContext!.docComment;
|
||||
}
|
||||
|
||||
const astModuleExportInfo: AstModuleExportInfo = this.astSymbolTable.fetchAstModuleExportInfo(astEntryPoint);
|
||||
|
||||
// Create a CollectorEntity for each top-level export.
|
||||
const processedAstEntities: AstEntity[] = [];
|
||||
for (const [exportName, astEntity] of astModuleExportInfo.exportedLocalEntities) {
|
||||
this._createCollectorEntity(astEntity, exportName);
|
||||
processedAstEntities.push(astEntity);
|
||||
}
|
||||
|
||||
// Recursively create the remaining CollectorEntities after the top-level entities
|
||||
// have been processed.
|
||||
const alreadySeenAstEntities: Set<AstEntity> = new Set<AstEntity>();
|
||||
for (const astEntity of processedAstEntities) {
|
||||
this._recursivelyCreateEntities(astEntity, alreadySeenAstEntities);
|
||||
if (astEntity instanceof AstSymbol) {
|
||||
this.fetchSymbolMetadata(astEntity);
|
||||
}
|
||||
}
|
||||
|
||||
this._makeUniqueNames();
|
||||
|
||||
for (const starExportedExternalModule of astModuleExportInfo.starExportedExternalModules) {
|
||||
if (starExportedExternalModule.externalModulePath !== undefined) {
|
||||
this._starExportedExternalModulePaths.push(starExportedExternalModule.externalModulePath);
|
||||
}
|
||||
}
|
||||
|
||||
Sort.sortBy(this._entities, (x) => x.getSortKey());
|
||||
Sort.sortSet(this._dtsTypeReferenceDirectives);
|
||||
Sort.sortSet(this._dtsLibReferenceDirectives);
|
||||
// eslint-disable-next-line @typescript-eslint/require-array-sort-compare
|
||||
this._starExportedExternalModulePaths.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* For a given ts.Identifier that is part of an AstSymbol that we analyzed, return the CollectorEntity 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): CollectorEntity | undefined {
|
||||
const astEntity: AstEntity | undefined = this.astSymbolTable.tryGetEntityForNode(identifier);
|
||||
if (astEntity) {
|
||||
return this._entitiesByAstEntity.get(astEntity);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* For a given analyzed ts.Symbol, return the CollectorEntity that it refers to. Returns undefined if it
|
||||
* doesn't refer to anything interesting.
|
||||
*/
|
||||
public tryGetEntityForSymbol(symbol: ts.Symbol): CollectorEntity | undefined {
|
||||
return this._entitiesBySymbol.get(symbol);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the associated `CollectorEntity` for the given `astEntity`, if one was created during analysis.
|
||||
*/
|
||||
public tryGetCollectorEntity(astEntity: AstEntity): CollectorEntity | undefined {
|
||||
return this._entitiesByAstEntity.get(astEntity);
|
||||
}
|
||||
|
||||
public fetchSymbolMetadata(astSymbol: AstSymbol): SymbolMetadata {
|
||||
if (astSymbol.symbolMetadata === undefined) {
|
||||
this._fetchSymbolMetadata(astSymbol);
|
||||
}
|
||||
|
||||
return astSymbol.symbolMetadata as SymbolMetadata;
|
||||
}
|
||||
|
||||
public fetchDeclarationMetadata(astDeclaration: AstDeclaration): DeclarationMetadata {
|
||||
if (astDeclaration.declarationMetadata === undefined) {
|
||||
// Fetching the SymbolMetadata always constructs the DeclarationMetadata
|
||||
this._fetchSymbolMetadata(astDeclaration.astSymbol);
|
||||
}
|
||||
|
||||
return astDeclaration.declarationMetadata as DeclarationMetadata;
|
||||
}
|
||||
|
||||
public fetchApiItemMetadata(astDeclaration: AstDeclaration): ApiItemMetadata {
|
||||
if (astDeclaration.apiItemMetadata === undefined) {
|
||||
// Fetching the SymbolMetadata always constructs the ApiItemMetadata
|
||||
this._fetchSymbolMetadata(astDeclaration.astSymbol);
|
||||
}
|
||||
|
||||
return astDeclaration.apiItemMetadata as ApiItemMetadata;
|
||||
}
|
||||
|
||||
public tryFetchMetadataForAstEntity(astEntity: AstEntity): SymbolMetadata | undefined {
|
||||
if (astEntity instanceof AstSymbol) {
|
||||
return this.fetchSymbolMetadata(astEntity);
|
||||
}
|
||||
|
||||
if (astEntity instanceof AstImport && astEntity.astSymbol) {
|
||||
return this.fetchSymbolMetadata(astEntity.astSymbol);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public isAncillaryDeclaration(astDeclaration: AstDeclaration): boolean {
|
||||
const declarationMetadata: DeclarationMetadata = this.fetchDeclarationMetadata(astDeclaration);
|
||||
return declarationMetadata.isAncillary;
|
||||
}
|
||||
|
||||
public getNonAncillaryDeclarations(astSymbol: AstSymbol): readonly AstDeclaration[] {
|
||||
const result: AstDeclaration[] = [];
|
||||
for (const astDeclaration of astSymbol.astDeclarations) {
|
||||
const declarationMetadata: DeclarationMetadata = this.fetchDeclarationMetadata(astDeclaration);
|
||||
if (!declarationMetadata.isAncillary) {
|
||||
result.push(astDeclaration);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the leading underscore, for example: "_Example" --\> "example*Example*_"
|
||||
*
|
||||
* @remarks
|
||||
* This causes internal definitions to sort alphabetically case-insensitive, then case-sensitive, and
|
||||
* initially ignoring the underscore prefix, while still deterministically comparing it.
|
||||
* The star is used as a delimiter because it is not a legal identifier character.
|
||||
*/
|
||||
public static getSortKeyIgnoringUnderscore(identifier: string | undefined): string {
|
||||
if (!identifier) return '';
|
||||
|
||||
let parts: string[];
|
||||
|
||||
if (identifier.startsWith('_')) {
|
||||
const withoutUnderscore: string = identifier.slice(1);
|
||||
parts = [withoutUnderscore.toLowerCase(), '*', withoutUnderscore, '*', '_'];
|
||||
} else {
|
||||
parts = [identifier.toLowerCase(), '*', identifier];
|
||||
}
|
||||
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* For function-like signatures, this returns the TSDoc "overload index" which can be used to identify
|
||||
* a specific overload.
|
||||
*/
|
||||
public getOverloadIndex(astDeclaration: AstDeclaration): number {
|
||||
const allDeclarations: readonly AstDeclaration[] = astDeclaration.astSymbol.astDeclarations;
|
||||
if (allDeclarations.length === 1) {
|
||||
return 1; // trivial case
|
||||
}
|
||||
|
||||
let overloadIndex: number | undefined = this._cachedOverloadIndexesByDeclaration.get(astDeclaration);
|
||||
|
||||
if (overloadIndex === undefined) {
|
||||
// TSDoc index selectors are positive integers counting from 1
|
||||
let nextIndex = 1;
|
||||
for (const other of allDeclarations) {
|
||||
// Filter out other declarations that are not overloads. For example, an overloaded function can also
|
||||
// be a namespace.
|
||||
if (other.declaration.kind === astDeclaration.declaration.kind) {
|
||||
this._cachedOverloadIndexesByDeclaration.set(other, nextIndex);
|
||||
++nextIndex;
|
||||
}
|
||||
}
|
||||
|
||||
overloadIndex = this._cachedOverloadIndexesByDeclaration.get(astDeclaration);
|
||||
}
|
||||
|
||||
if (overloadIndex === undefined) {
|
||||
// This should never happen
|
||||
throw new InternalError('Error calculating overload index for declaration');
|
||||
}
|
||||
|
||||
return overloadIndex;
|
||||
}
|
||||
|
||||
private _createCollectorEntity(astEntity: AstEntity, exportName?: string, parent?: CollectorEntity): CollectorEntity {
|
||||
let entity: CollectorEntity | undefined = this._entitiesByAstEntity.get(astEntity);
|
||||
|
||||
if (!entity) {
|
||||
entity = new CollectorEntity(astEntity);
|
||||
|
||||
this._entitiesByAstEntity.set(astEntity, entity);
|
||||
if (astEntity instanceof AstSymbol) {
|
||||
this._entitiesBySymbol.set(astEntity.followedSymbol, entity);
|
||||
} else if (astEntity instanceof AstNamespaceImport) {
|
||||
this._entitiesBySymbol.set(astEntity.symbol, entity);
|
||||
}
|
||||
|
||||
this._entities.push(entity);
|
||||
this._collectReferenceDirectives(astEntity);
|
||||
}
|
||||
|
||||
if (exportName) {
|
||||
if (parent) {
|
||||
entity.addLocalExportName(exportName, parent);
|
||||
} else {
|
||||
entity.addExportName(exportName);
|
||||
}
|
||||
}
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
private _recursivelyCreateEntities(astEntity: AstEntity, alreadySeenAstEntities: Set<AstEntity>): void {
|
||||
if (alreadySeenAstEntities.has(astEntity)) return;
|
||||
alreadySeenAstEntities.add(astEntity);
|
||||
|
||||
if (astEntity instanceof AstSymbol) {
|
||||
astEntity.forEachDeclarationRecursive((astDeclaration: AstDeclaration) => {
|
||||
for (const referencedAstEntity of astDeclaration.referencedAstEntities) {
|
||||
if (referencedAstEntity instanceof AstSymbol) {
|
||||
// We only create collector entities for root-level symbols. For example, if a symbol is
|
||||
// nested inside a namespace, only the namespace gets a collector entity. Note that this
|
||||
// is not true for AstNamespaceImports below.
|
||||
if (referencedAstEntity.parentAstSymbol === undefined) {
|
||||
this._createCollectorEntity(referencedAstEntity);
|
||||
}
|
||||
} else {
|
||||
this._createCollectorEntity(referencedAstEntity);
|
||||
}
|
||||
|
||||
this._recursivelyCreateEntities(referencedAstEntity, alreadySeenAstEntities);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (astEntity instanceof AstNamespaceImport) {
|
||||
const astModuleExportInfo: AstModuleExportInfo = astEntity.fetchAstModuleExportInfo(this);
|
||||
const parentEntity: CollectorEntity | undefined = this._entitiesByAstEntity.get(astEntity);
|
||||
if (!parentEntity) {
|
||||
// This should never happen, as we've already created entities for all AstNamespaceImports.
|
||||
throw new InternalError(
|
||||
`Failed to get CollectorEntity for AstNamespaceImport with namespace name "${astEntity.namespaceName}"`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const [localExportName, localAstEntity] of astModuleExportInfo.exportedLocalEntities) {
|
||||
// Create a CollectorEntity for each local export within an AstNamespaceImport entity.
|
||||
this._createCollectorEntity(localAstEntity, localExportName, parentEntity);
|
||||
this._recursivelyCreateEntities(localAstEntity, alreadySeenAstEntities);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a unique name for each item in the package typings file.
|
||||
*/
|
||||
private _makeUniqueNames(): void {
|
||||
// The following examples illustrate the nameForEmit heuristics:
|
||||
//
|
||||
// Example 1:
|
||||
// class X { } <--- nameForEmit should be "A" to simplify things and reduce possibility of conflicts
|
||||
// export { X as A };
|
||||
//
|
||||
// Example 2:
|
||||
// class X { } <--- nameForEmit should be "X" because choosing A or B would be nondeterministic
|
||||
// export { X as A };
|
||||
// export { X as B };
|
||||
//
|
||||
// Example 3:
|
||||
// class X { } <--- nameForEmit should be "X_1" because Y has a stronger claim to the name
|
||||
// export { X as A };
|
||||
// export { X as B };
|
||||
// class Y { } <--- nameForEmit should be "X"
|
||||
// export { Y as X };
|
||||
|
||||
// Set of names that should NOT be used when generating a unique nameForEmit
|
||||
const usedNames: Set<string> = new Set<string>();
|
||||
|
||||
// First collect the names of explicit package exports, and perform a sanity check.
|
||||
for (const entity of this._entities) {
|
||||
for (const exportName of entity.exportNames) {
|
||||
if (usedNames.has(exportName)) {
|
||||
// This should be impossible
|
||||
throw new InternalError(`A package cannot have two exports with the name "${exportName}"`);
|
||||
}
|
||||
|
||||
usedNames.add(exportName);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure that each entity has a unique nameForEmit
|
||||
for (const entity of this._entities) {
|
||||
// What name would we ideally want to emit it as?
|
||||
let idealNameForEmit: string;
|
||||
|
||||
// If this entity is exported exactly once, then we prefer the exported name
|
||||
if (entity.singleExportName !== undefined && entity.singleExportName !== ts.InternalSymbolName.Default) {
|
||||
idealNameForEmit = entity.singleExportName;
|
||||
} else {
|
||||
// otherwise use the local name
|
||||
idealNameForEmit = entity.astEntity.localName;
|
||||
}
|
||||
|
||||
if (idealNameForEmit.includes('.')) {
|
||||
// For an ImportType with a namespace chain, only the top namespace is imported.
|
||||
idealNameForEmit = idealNameForEmit.split('.')[0]!;
|
||||
}
|
||||
|
||||
// If the idealNameForEmit happens to be the same as one of the exports, then we're safe to use that...
|
||||
if (
|
||||
entity.exportNames.has(idealNameForEmit) && // ...except that if it conflicts with a global name, then the global name wins
|
||||
!this.globalVariableAnalyzer.hasGlobalName(idealNameForEmit) && // ...also avoid "default" which can interfere with "export { default } from 'some-module;'"
|
||||
idealNameForEmit !== 'default'
|
||||
) {
|
||||
entity.nameForEmit = idealNameForEmit;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Generate a unique name based on idealNameForEmit
|
||||
let suffix = 1;
|
||||
let nameForEmit: string = idealNameForEmit;
|
||||
|
||||
// Choose a name that doesn't conflict with usedNames or a global name
|
||||
while (
|
||||
nameForEmit === 'default' ||
|
||||
usedNames.has(nameForEmit) ||
|
||||
this.globalVariableAnalyzer.hasGlobalName(nameForEmit)
|
||||
) {
|
||||
nameForEmit = `${idealNameForEmit}_${++suffix}`;
|
||||
}
|
||||
|
||||
entity.nameForEmit = nameForEmit;
|
||||
usedNames.add(nameForEmit);
|
||||
}
|
||||
}
|
||||
|
||||
private _fetchSymbolMetadata(astSymbol: AstSymbol): void {
|
||||
if (astSymbol.symbolMetadata) {
|
||||
return;
|
||||
}
|
||||
|
||||
// When we solve an astSymbol, then we always also solve all of its parents and all of its declarations.
|
||||
// The parent is solved first.
|
||||
if (astSymbol.parentAstSymbol && astSymbol.parentAstSymbol.symbolMetadata === undefined) {
|
||||
this._fetchSymbolMetadata(astSymbol.parentAstSymbol);
|
||||
}
|
||||
|
||||
// Construct the DeclarationMetadata objects, and detect any ancillary declarations
|
||||
this._calculateDeclarationMetadataForDeclarations(astSymbol);
|
||||
|
||||
// Calculate the ApiItemMetadata objects
|
||||
for (const astDeclaration of astSymbol.astDeclarations) {
|
||||
this._calculateApiItemMetadata(astDeclaration);
|
||||
}
|
||||
|
||||
// The most public effectiveReleaseTag for all declarations
|
||||
let maxEffectiveReleaseTag: ReleaseTag = ReleaseTag.None;
|
||||
|
||||
for (const astDeclaration of astSymbol.astDeclarations) {
|
||||
// We know we solved this above
|
||||
const apiItemMetadata: ApiItemMetadata = astDeclaration.apiItemMetadata as ApiItemMetadata;
|
||||
|
||||
const effectiveReleaseTag: ReleaseTag = apiItemMetadata.effectiveReleaseTag;
|
||||
|
||||
if (effectiveReleaseTag > maxEffectiveReleaseTag) {
|
||||
maxEffectiveReleaseTag = effectiveReleaseTag;
|
||||
}
|
||||
}
|
||||
|
||||
// Update this last when we're sure no exceptions were thrown
|
||||
astSymbol.symbolMetadata = new SymbolMetadata({
|
||||
maxEffectiveReleaseTag,
|
||||
});
|
||||
}
|
||||
|
||||
private _calculateDeclarationMetadataForDeclarations(astSymbol: AstSymbol): void {
|
||||
// Initialize DeclarationMetadata for each declaration
|
||||
for (const astDeclaration of astSymbol.astDeclarations) {
|
||||
if (astDeclaration.declarationMetadata) {
|
||||
throw new InternalError('AstDeclaration.declarationMetadata is not expected to have been initialized yet');
|
||||
}
|
||||
|
||||
const metadata: InternalDeclarationMetadata = new InternalDeclarationMetadata();
|
||||
metadata.tsdocParserContext = this._parseTsdocForAstDeclaration(astDeclaration);
|
||||
|
||||
astDeclaration.declarationMetadata = metadata;
|
||||
}
|
||||
|
||||
// Detect ancillary declarations
|
||||
for (const astDeclaration of astSymbol.astDeclarations) {
|
||||
// For a getter/setter pair, make the setter ancillary to the getter
|
||||
if (astDeclaration.declaration.kind === ts.SyntaxKind.SetAccessor) {
|
||||
let foundGetter = false;
|
||||
for (const getterAstDeclaration of astDeclaration.astSymbol.astDeclarations) {
|
||||
if (getterAstDeclaration.declaration.kind === ts.SyntaxKind.GetAccessor) {
|
||||
// Associate it with the getter
|
||||
this._addAncillaryDeclaration(getterAstDeclaration, astDeclaration);
|
||||
|
||||
foundGetter = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundGetter) {
|
||||
this.messageRouter.addAnalyzerIssue(
|
||||
ExtractorMessageId.MissingGetter,
|
||||
`The property "${astDeclaration.astSymbol.localName}" has a setter but no getter.`,
|
||||
astDeclaration,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _addAncillaryDeclaration(mainAstDeclaration: AstDeclaration, ancillaryAstDeclaration: AstDeclaration): void {
|
||||
const mainMetadata: InternalDeclarationMetadata =
|
||||
mainAstDeclaration.declarationMetadata as InternalDeclarationMetadata;
|
||||
const ancillaryMetadata: InternalDeclarationMetadata =
|
||||
ancillaryAstDeclaration.declarationMetadata as InternalDeclarationMetadata;
|
||||
|
||||
if (mainMetadata.ancillaryDeclarations.includes(ancillaryAstDeclaration)) {
|
||||
return; // already added
|
||||
}
|
||||
|
||||
if (mainAstDeclaration.astSymbol !== ancillaryAstDeclaration.astSymbol) {
|
||||
throw new InternalError(
|
||||
'Invalid call to _addAncillaryDeclaration() because declarations do not belong to the same symbol',
|
||||
);
|
||||
}
|
||||
|
||||
if (mainMetadata.isAncillary) {
|
||||
throw new InternalError('Invalid call to _addAncillaryDeclaration() because the target is ancillary itself');
|
||||
}
|
||||
|
||||
if (ancillaryMetadata.isAncillary) {
|
||||
throw new InternalError(
|
||||
'Invalid call to _addAncillaryDeclaration() because source is already ancillary to another declaration',
|
||||
);
|
||||
}
|
||||
|
||||
if (mainAstDeclaration.apiItemMetadata || ancillaryAstDeclaration.apiItemMetadata) {
|
||||
throw new InternalError(
|
||||
'Invalid call to _addAncillaryDeclaration() because the API item metadata has already been constructed',
|
||||
);
|
||||
}
|
||||
|
||||
ancillaryMetadata.isAncillary = true;
|
||||
mainMetadata.ancillaryDeclarations.push(ancillaryAstDeclaration);
|
||||
}
|
||||
|
||||
private _calculateApiItemMetadata(astDeclaration: AstDeclaration): void {
|
||||
const declarationMetadata: InternalDeclarationMetadata =
|
||||
astDeclaration.declarationMetadata as InternalDeclarationMetadata;
|
||||
if (declarationMetadata.isAncillary) {
|
||||
if (astDeclaration.declaration.kind === ts.SyntaxKind.SetAccessor && declarationMetadata.tsdocParserContext) {
|
||||
this.messageRouter.addAnalyzerIssue(
|
||||
ExtractorMessageId.SetterWithDocs,
|
||||
`The doc comment for the property "${astDeclaration.astSymbol.localName}"` +
|
||||
` must appear on the getter, not the setter.`,
|
||||
astDeclaration,
|
||||
);
|
||||
}
|
||||
|
||||
// We never calculate ApiItemMetadata for an ancillary declaration; instead, it is assigned when
|
||||
// the main declaration is processed.
|
||||
return;
|
||||
}
|
||||
|
||||
const options: IApiItemMetadataOptions = {
|
||||
declaredReleaseTag: ReleaseTag.None,
|
||||
effectiveReleaseTag: ReleaseTag.None,
|
||||
isEventProperty: false,
|
||||
isOverride: false,
|
||||
isSealed: false,
|
||||
isVirtual: false,
|
||||
isPreapproved: false,
|
||||
releaseTagSameAsParent: false,
|
||||
};
|
||||
|
||||
const parserContext: tsdoc.ParserContext | undefined = declarationMetadata.tsdocParserContext;
|
||||
if (parserContext) {
|
||||
const modifierTagSet: tsdoc.StandardModifierTagSet = parserContext.docComment.modifierTagSet;
|
||||
|
||||
let declaredReleaseTag: ReleaseTag = ReleaseTag.None;
|
||||
let extraReleaseTags = false;
|
||||
|
||||
if (modifierTagSet.isPublic()) {
|
||||
declaredReleaseTag = ReleaseTag.Public;
|
||||
}
|
||||
|
||||
if (modifierTagSet.isBeta()) {
|
||||
if (declaredReleaseTag === ReleaseTag.None) {
|
||||
declaredReleaseTag = ReleaseTag.Beta;
|
||||
} else {
|
||||
extraReleaseTags = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (modifierTagSet.isAlpha()) {
|
||||
if (declaredReleaseTag === ReleaseTag.None) {
|
||||
declaredReleaseTag = ReleaseTag.Alpha;
|
||||
} else {
|
||||
extraReleaseTags = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (modifierTagSet.isInternal()) {
|
||||
if (declaredReleaseTag === ReleaseTag.None) {
|
||||
declaredReleaseTag = ReleaseTag.Internal;
|
||||
} else {
|
||||
extraReleaseTags = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (extraReleaseTags && !astDeclaration.astSymbol.isExternal) {
|
||||
// for now, don't report errors for external code
|
||||
this.messageRouter.addAnalyzerIssue(
|
||||
ExtractorMessageId.ExtraReleaseTag,
|
||||
'The doc comment should not contain more than one release tag',
|
||||
astDeclaration,
|
||||
);
|
||||
}
|
||||
|
||||
options.declaredReleaseTag = declaredReleaseTag;
|
||||
|
||||
options.isEventProperty = modifierTagSet.isEventProperty();
|
||||
options.isOverride = modifierTagSet.isOverride();
|
||||
options.isSealed = modifierTagSet.isSealed();
|
||||
options.isVirtual = modifierTagSet.isVirtual();
|
||||
const preapprovedTag: tsdoc.TSDocTagDefinition | undefined =
|
||||
this.extractorConfig.tsdocConfiguration.tryGetTagDefinition('@preapproved');
|
||||
|
||||
if (preapprovedTag && modifierTagSet.hasTag(preapprovedTag)) {
|
||||
// This feature only makes sense for potentially big declarations.
|
||||
switch (astDeclaration.declaration.kind) {
|
||||
case ts.SyntaxKind.ClassDeclaration:
|
||||
case ts.SyntaxKind.EnumDeclaration:
|
||||
case ts.SyntaxKind.InterfaceDeclaration:
|
||||
case ts.SyntaxKind.ModuleDeclaration:
|
||||
if (declaredReleaseTag === ReleaseTag.Internal) {
|
||||
options.isPreapproved = true;
|
||||
} else {
|
||||
this.messageRouter.addAnalyzerIssue(
|
||||
ExtractorMessageId.PreapprovedBadReleaseTag,
|
||||
`The @preapproved tag cannot be applied to "${astDeclaration.astSymbol.localName}"` +
|
||||
` without an @internal release tag`,
|
||||
astDeclaration,
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
default:
|
||||
this.messageRouter.addAnalyzerIssue(
|
||||
ExtractorMessageId.PreapprovedUnsupportedType,
|
||||
`The @preapproved tag cannot be applied to "${astDeclaration.astSymbol.localName}"` +
|
||||
` because it is not a supported declaration type`,
|
||||
astDeclaration,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This needs to be set regardless of whether or not a parserContext exists
|
||||
if (astDeclaration.parent) {
|
||||
const parentApiItemMetadata: ApiItemMetadata = this.fetchApiItemMetadata(astDeclaration.parent);
|
||||
options.effectiveReleaseTag =
|
||||
options.declaredReleaseTag === ReleaseTag.None
|
||||
? parentApiItemMetadata.effectiveReleaseTag
|
||||
: options.declaredReleaseTag;
|
||||
|
||||
options.releaseTagSameAsParent = parentApiItemMetadata.effectiveReleaseTag === options.effectiveReleaseTag;
|
||||
} else {
|
||||
options.effectiveReleaseTag = options.declaredReleaseTag;
|
||||
}
|
||||
|
||||
if (options.effectiveReleaseTag === ReleaseTag.None) {
|
||||
if (!astDeclaration.astSymbol.isExternal) {
|
||||
// for now, don't report errors for external code
|
||||
// Don't report missing release tags for forgotten exports (unless we're including forgotten exports
|
||||
// in either the API report or doc model).
|
||||
const astSymbol: AstSymbol = astDeclaration.astSymbol;
|
||||
const entity: CollectorEntity | undefined = this._entitiesByAstEntity.get(astSymbol.rootAstSymbol);
|
||||
if (
|
||||
entity &&
|
||||
(entity.consumable ||
|
||||
this.extractorConfig.apiReportIncludeForgottenExports ||
|
||||
this.extractorConfig.docModelIncludeForgottenExports) && // We also don't report errors for the default export of an entry point, since its doc comment
|
||||
// isn't easy to obtain from the .d.ts file
|
||||
astSymbol.rootAstSymbol.localName !== '_default'
|
||||
) {
|
||||
this.messageRouter.addAnalyzerIssue(
|
||||
ExtractorMessageId.MissingReleaseTag,
|
||||
`"${entity.astEntity.localName}" is part of the package's API, but it is missing ` +
|
||||
`a release tag (@alpha, @beta, @public, or @internal)`,
|
||||
astSymbol,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
options.effectiveReleaseTag = ReleaseTag.Public;
|
||||
}
|
||||
|
||||
const apiItemMetadata: ApiItemMetadata = new ApiItemMetadata(options);
|
||||
if (parserContext) {
|
||||
apiItemMetadata.tsdocComment = parserContext.docComment;
|
||||
}
|
||||
|
||||
astDeclaration.apiItemMetadata = apiItemMetadata;
|
||||
|
||||
// Lastly, share the result with any ancillary declarations
|
||||
for (const ancillaryDeclaration of declarationMetadata.ancillaryDeclarations) {
|
||||
ancillaryDeclaration.apiItemMetadata = apiItemMetadata;
|
||||
}
|
||||
}
|
||||
|
||||
private _parseTsdocForAstDeclaration(astDeclaration: AstDeclaration): tsdoc.ParserContext | undefined {
|
||||
const declaration: ts.Declaration = astDeclaration.declaration;
|
||||
let nodeForComment: ts.Node = declaration;
|
||||
|
||||
if (ts.isVariableDeclaration(declaration)) {
|
||||
// Variable declarations are special because they can be combined into a list. For example:
|
||||
//
|
||||
// /** A */ export /** B */ const /** C */ x = 1, /** D **/ [ /** E */ y, z] = [3, 4];
|
||||
//
|
||||
// The compiler will only emit comments A and C in the .d.ts file, so in general there isn't a well-defined
|
||||
// way to document these parts. API Extractor requires you to break them into separate exports like this:
|
||||
//
|
||||
// /** A */ export const x = 1;
|
||||
//
|
||||
// But _getReleaseTagForDeclaration() still receives a node corresponding to "x", so we need to walk upwards
|
||||
// and find the containing statement in order for getJSDocCommentRanges() to read the comment that we expect.
|
||||
const statement: ts.VariableStatement | undefined = TypeScriptHelpers.findFirstParent(
|
||||
declaration,
|
||||
ts.SyntaxKind.VariableStatement,
|
||||
) as ts.VariableStatement | undefined;
|
||||
if (
|
||||
statement !== undefined && // For a compound declaration, fall back to looking for C instead of A
|
||||
statement.declarationList.declarations.length === 1
|
||||
) {
|
||||
nodeForComment = statement;
|
||||
}
|
||||
}
|
||||
|
||||
const sourceFileText: string = declaration.getSourceFile().text;
|
||||
const ranges: ts.CommentRange[] = TypeScriptInternals.getJSDocCommentRanges(nodeForComment, sourceFileText) ?? [];
|
||||
|
||||
if (ranges.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// We use the JSDoc comment block that is closest to the definition, i.e.
|
||||
// the last one preceding it
|
||||
const range: ts.TextRange = ranges[ranges.length - 1]!;
|
||||
|
||||
const tsdocTextRange: tsdoc.TextRange = tsdoc.TextRange.fromStringRange(sourceFileText, range.pos, range.end);
|
||||
|
||||
const parserContext: tsdoc.ParserContext = this._tsdocParser.parseRange(tsdocTextRange);
|
||||
|
||||
this.messageRouter.addTsdocMessages(parserContext, declaration.getSourceFile(), astDeclaration);
|
||||
|
||||
// We delete the @privateRemarks block as early as possible, to ensure that it never leaks through
|
||||
// into one of the output files.
|
||||
parserContext.docComment.privateRemarks = undefined;
|
||||
|
||||
return parserContext;
|
||||
}
|
||||
|
||||
private _collectReferenceDirectives(astEntity: AstEntity): void {
|
||||
if (astEntity instanceof AstSymbol) {
|
||||
const sourceFiles: ts.SourceFile[] = astEntity.astDeclarations.map((astDeclaration) =>
|
||||
astDeclaration.declaration.getSourceFile(),
|
||||
);
|
||||
this._collectReferenceDirectivesFromSourceFiles(sourceFiles);
|
||||
return;
|
||||
}
|
||||
|
||||
if (astEntity instanceof AstNamespaceImport) {
|
||||
const sourceFiles: ts.SourceFile[] = [astEntity.astModule.sourceFile];
|
||||
this._collectReferenceDirectivesFromSourceFiles(sourceFiles);
|
||||
}
|
||||
}
|
||||
|
||||
private _collectReferenceDirectivesFromSourceFiles(sourceFiles: ts.SourceFile[]): void {
|
||||
const seenFilenames: Set<string> = new Set<string>();
|
||||
|
||||
for (const sourceFile of sourceFiles) {
|
||||
if (sourceFile?.fileName && !seenFilenames.has(sourceFile.fileName)) {
|
||||
seenFilenames.add(sourceFile.fileName);
|
||||
|
||||
for (const typeReferenceDirective of sourceFile.typeReferenceDirectives) {
|
||||
const name: string = sourceFile.text.slice(typeReferenceDirective.pos, typeReferenceDirective.end);
|
||||
this._dtsTypeReferenceDirectives.add(name);
|
||||
}
|
||||
|
||||
for (const libReferenceDirective of sourceFile.libReferenceDirectives) {
|
||||
const name: string = sourceFile.text.slice(libReferenceDirective.pos, libReferenceDirective.end);
|
||||
this._dtsLibReferenceDirectives.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
241
packages/api-extractor/src/collector/CollectorEntity.ts
Normal file
241
packages/api-extractor/src/collector/CollectorEntity.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
import { Sort } from '@rushstack/node-core-library';
|
||||
import * as ts from 'typescript';
|
||||
import type { AstEntity } from '../analyzer/AstEntity.js';
|
||||
import { AstSymbol } from '../analyzer/AstSymbol.js';
|
||||
import { Collector } from './Collector.js';
|
||||
|
||||
/**
|
||||
* This is a data structure used by the Collector to track an AstEntity that may be emitted in the *.d.ts file.
|
||||
*
|
||||
* @remarks
|
||||
* The additional contextual state beyond AstSymbol is:
|
||||
* - Whether it's an export of this entry point or not
|
||||
* - The nameForEmit, which may get renamed by DtsRollupGenerator._makeUniqueNames()
|
||||
* - The export name (or names, if the same symbol is exported multiple times)
|
||||
*/
|
||||
export class CollectorEntity {
|
||||
/**
|
||||
* The AstEntity that this entry represents.
|
||||
*/
|
||||
public readonly astEntity: AstEntity;
|
||||
|
||||
private _exportNames: Set<string> = new Set();
|
||||
|
||||
private _exportNamesSorted: boolean = false;
|
||||
|
||||
private _singleExportName: string | undefined = undefined;
|
||||
|
||||
private _localExportNamesByParent: Map<CollectorEntity, Set<string>> = new Map();
|
||||
|
||||
private _nameForEmit: string | undefined = undefined;
|
||||
|
||||
private _sortKey: string | undefined = undefined;
|
||||
|
||||
public constructor(astEntity: AstEntity) {
|
||||
this.astEntity = astEntity;
|
||||
}
|
||||
|
||||
/**
|
||||
* The declaration name that will be emitted in the .d.ts rollup, .api.md, and .api.json files. Generated by
|
||||
* `Collector._makeUniqueNames`. Be aware that the declaration may be renamed to avoid conflicts with (1)
|
||||
* global names (e.g. `Promise`) and (2) if local, other local names across different files.
|
||||
*/
|
||||
public get nameForEmit(): string | undefined {
|
||||
return this._nameForEmit;
|
||||
}
|
||||
|
||||
public set nameForEmit(value: string | undefined) {
|
||||
this._nameForEmit = value;
|
||||
this._sortKey = undefined; // invalidate the cached value
|
||||
}
|
||||
|
||||
/**
|
||||
* The list of export names if this symbol is exported from the entry point.
|
||||
*
|
||||
* @remarks
|
||||
* Note that a given symbol may be exported more than once:
|
||||
* ```
|
||||
* class X { }
|
||||
* export { X }
|
||||
* export { X as Y }
|
||||
* ```
|
||||
*/
|
||||
public get exportNames(): ReadonlySet<string> {
|
||||
if (!this._exportNamesSorted) {
|
||||
Sort.sortSet(this._exportNames);
|
||||
this._exportNamesSorted = true;
|
||||
}
|
||||
|
||||
return this._exportNames;
|
||||
}
|
||||
|
||||
/**
|
||||
* If exportNames contains only one string, then singleExportName is that string.
|
||||
* In all other cases, it is undefined.
|
||||
*/
|
||||
public get singleExportName(): string | undefined {
|
||||
return this._singleExportName;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is true if exportNames contains only one string, and the declaration can be exported using the inline syntax
|
||||
* such as "export class X \{ \}" instead of "export \{ X \}".
|
||||
*/
|
||||
public get shouldInlineExport(): boolean {
|
||||
// We don't inline an AstImport
|
||||
return (
|
||||
this.astEntity instanceof AstSymbol && // We don't inline a symbol with more than one exported name
|
||||
this._singleExportName !== undefined &&
|
||||
this._singleExportName !== ts.InternalSymbolName.Default && // We can't inline a symbol whose emitted name is different from the export name
|
||||
(this._nameForEmit === undefined || this._nameForEmit === this._singleExportName)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates that this entity is exported from the package entry point. Compare to `CollectorEntity.exported`.
|
||||
*/
|
||||
public get exportedFromEntryPoint(): boolean {
|
||||
return this.exportNames.size > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates that this entity is exported from its parent module (i.e. either the package entry point or
|
||||
* a local namespace). Compare to `CollectorEntity.consumable`.
|
||||
*
|
||||
* @remarks
|
||||
* In the example below:
|
||||
*
|
||||
* ```ts
|
||||
* declare function add(): void;
|
||||
* declare namespace calculator {
|
||||
* export {
|
||||
* add
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Namespace `calculator` is neither exported nor consumable, function `add` is exported (from `calculator`)
|
||||
* but not consumable.
|
||||
*/
|
||||
public get exported(): boolean {
|
||||
// Exported from top-level?
|
||||
if (this.exportedFromEntryPoint) return true;
|
||||
|
||||
// Exported from parent?
|
||||
for (const localExportNames of this._localExportNamesByParent.values()) {
|
||||
if (localExportNames.size > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates that it is possible for a consumer of the API to "consume" this entity, either by importing
|
||||
* it directly or via a namespace. If an entity is not consumable, then API Extractor will report an
|
||||
* `ae-forgotten-export` warning. Compare to `CollectorEntity.exported`.
|
||||
*
|
||||
* @remarks
|
||||
* An API item is consumable if:
|
||||
*
|
||||
* 1. It is exported from the top-level entry point OR
|
||||
* 2. It is exported from a consumable parent entity.
|
||||
*
|
||||
* For an example of #2, consider how `AstNamespaceImport` entities are processed. A generated rollup.d.ts
|
||||
* might look like this:
|
||||
*
|
||||
* ```ts
|
||||
* declare function add(): void;
|
||||
* declare namespace calculator {
|
||||
* export {
|
||||
* add
|
||||
* }
|
||||
* }
|
||||
* export { calculator }
|
||||
* ```
|
||||
*
|
||||
* In this example, `add` is exported via the consumable `calculator` namespace.
|
||||
*/
|
||||
public get consumable(): boolean {
|
||||
// Exported from top-level?
|
||||
if (this.exportedFromEntryPoint) return true;
|
||||
|
||||
// Exported from consumable parent?
|
||||
for (const [parent, localExportNames] of this._localExportNamesByParent) {
|
||||
if (localExportNames.size > 0 && parent.consumable) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the first consumable parent that exports this entity. If there is none, returns
|
||||
* `undefined`.
|
||||
*/
|
||||
public getFirstExportingConsumableParent(): CollectorEntity | undefined {
|
||||
for (const [parent, localExportNames] of this._localExportNamesByParent) {
|
||||
if (parent.consumable && localExportNames.size > 0) {
|
||||
return parent;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new export name to the entity.
|
||||
*/
|
||||
public addExportName(exportName: string): void {
|
||||
if (!this._exportNames.has(exportName)) {
|
||||
this._exportNamesSorted = false;
|
||||
this._exportNames.add(exportName);
|
||||
|
||||
if (this._exportNames.size === 1) {
|
||||
this._singleExportName = exportName;
|
||||
} else {
|
||||
this._singleExportName = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new local export name to the entity.
|
||||
*
|
||||
* @remarks
|
||||
* In the example below:
|
||||
*
|
||||
* ```ts
|
||||
* declare function add(): void;
|
||||
* declare namespace calculator {
|
||||
* export {
|
||||
* add
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* `add` is the local export name for the `CollectorEntity` for `add`.
|
||||
*/
|
||||
public addLocalExportName(localExportName: string, parent: CollectorEntity): void {
|
||||
const localExportNames: Set<string> = this._localExportNamesByParent.get(parent) ?? new Set();
|
||||
localExportNames.add(localExportName);
|
||||
|
||||
this._localExportNamesByParent.set(parent, localExportNames);
|
||||
}
|
||||
|
||||
/**
|
||||
* A sorting key used by DtsRollupGenerator._makeUniqueNames()
|
||||
*/
|
||||
public getSortKey(): string {
|
||||
if (!this._sortKey) {
|
||||
this._sortKey = Collector.getSortKeyIgnoringUnderscore(this.nameForEmit ?? this.astEntity.localName);
|
||||
}
|
||||
|
||||
return this._sortKey;
|
||||
}
|
||||
}
|
||||
49
packages/api-extractor/src/collector/DeclarationMetadata.ts
Normal file
49
packages/api-extractor/src/collector/DeclarationMetadata.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
import type * as tsdoc from '@microsoft/tsdoc';
|
||||
import type { AstDeclaration } from '../analyzer/AstDeclaration.js';
|
||||
|
||||
/**
|
||||
* Stores the Collector's additional analysis for a specific `AstDeclaration` signature. This object is assigned to
|
||||
* `AstDeclaration.declarationMetadata` but consumers must always obtain it by calling
|
||||
* `Collector.fetchDeclarationMetadata()`.
|
||||
*
|
||||
* Note that ancillary declarations share their `ApiItemMetadata` with the main declaration,
|
||||
* whereas a separate `DeclarationMetadata` object is created for each declaration.
|
||||
*/
|
||||
export abstract class DeclarationMetadata {
|
||||
/**
|
||||
* The ParserContext from when the TSDoc comment was parsed from the source code.
|
||||
* If the source code did not contain a doc comment, then this will be undefined.
|
||||
*
|
||||
* Note that if an ancillary declaration has a doc comment, it is tracked here, whereas
|
||||
* `ApiItemMetadata.tsdocComment` corresponds to documentation for the main declaration.
|
||||
*/
|
||||
public abstract readonly tsdocParserContext: tsdoc.ParserContext | undefined;
|
||||
|
||||
/**
|
||||
* If true, then this declaration is treated as part of another declaration.
|
||||
*/
|
||||
public abstract readonly isAncillary: boolean;
|
||||
|
||||
/**
|
||||
* A list of other declarations that are treated as being part of this declaration. For example, a property
|
||||
* getter/setter pair will be treated as a single API item, with the setter being treated as ancillary to the getter.
|
||||
*
|
||||
* If the `ancillaryDeclarations` array is non-empty, then `isAncillary` will be false for this declaration,
|
||||
* and `isAncillary` will be true for all the array items.
|
||||
*/
|
||||
public abstract readonly ancillaryDeclarations: readonly AstDeclaration[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Used internally by the `Collector` to build up `DeclarationMetadata`.
|
||||
*/
|
||||
export class InternalDeclarationMetadata extends DeclarationMetadata {
|
||||
public tsdocParserContext: tsdoc.ParserContext | undefined = undefined;
|
||||
|
||||
public isAncillary: boolean = false;
|
||||
|
||||
public ancillaryDeclarations: AstDeclaration[] = [];
|
||||
}
|
||||
648
packages/api-extractor/src/collector/MessageRouter.ts
Normal file
648
packages/api-extractor/src/collector/MessageRouter.ts
Normal file
@@ -0,0 +1,648 @@
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
import type * as tsdoc from '@microsoft/tsdoc';
|
||||
import { Sort, InternalError } from '@rushstack/node-core-library';
|
||||
import colors from 'colors';
|
||||
import * as ts from 'typescript';
|
||||
import { AstDeclaration } from '../analyzer/AstDeclaration.js';
|
||||
import type { AstSymbol } from '../analyzer/AstSymbol.js';
|
||||
import { ConsoleMessageId } from '../api/ConsoleMessageId.js';
|
||||
import { ExtractorLogLevel } from '../api/ExtractorLogLevel.js';
|
||||
import {
|
||||
ExtractorMessage,
|
||||
ExtractorMessageCategory,
|
||||
type IExtractorMessageOptions,
|
||||
type IExtractorMessageProperties,
|
||||
} from '../api/ExtractorMessage.js';
|
||||
import { type ExtractorMessageId, allExtractorMessageIds } from '../api/ExtractorMessageId.js';
|
||||
import type { IExtractorMessagesConfig, IConfigMessageReportingRule } from '../api/IConfigFile.js';
|
||||
import type { ISourceLocation, SourceMapper } from './SourceMapper.js';
|
||||
|
||||
interface IReportingRule {
|
||||
addToApiReportFile: boolean;
|
||||
logLevel: ExtractorLogLevel;
|
||||
}
|
||||
|
||||
export interface IMessageRouterOptions {
|
||||
messageCallback: ((message: ExtractorMessage) => void) | undefined;
|
||||
messagesConfig: IExtractorMessagesConfig;
|
||||
showDiagnostics: boolean;
|
||||
showVerboseMessages: boolean;
|
||||
sourceMapper: SourceMapper;
|
||||
tsdocConfiguration: tsdoc.TSDocConfiguration;
|
||||
workingPackageFolder: string | undefined;
|
||||
}
|
||||
|
||||
export interface IBuildJsonDumpObjectOptions {
|
||||
/**
|
||||
* {@link MessageRouter.buildJsonDumpObject} will omit any objects keys with these names.
|
||||
*/
|
||||
keyNamesToOmit?: string[];
|
||||
}
|
||||
|
||||
export class MessageRouter {
|
||||
public static readonly DIAGNOSTICS_LINE: string = '============================================================';
|
||||
|
||||
private readonly _workingPackageFolder: string | undefined;
|
||||
|
||||
private readonly _messageCallback: ((message: ExtractorMessage) => void) | undefined;
|
||||
|
||||
// All messages
|
||||
private readonly _messages: ExtractorMessage[];
|
||||
|
||||
// For each AstDeclaration, the messages associated with it. This is used when addToApiReportFile=true
|
||||
private readonly _associatedMessagesForAstDeclaration: Map<AstDeclaration, ExtractorMessage[]>;
|
||||
|
||||
private readonly _sourceMapper: SourceMapper;
|
||||
|
||||
private readonly _tsdocConfiguration: tsdoc.TSDocConfiguration;
|
||||
|
||||
// Normalized representation of the routing rules from api-extractor.json
|
||||
private _reportingRuleByMessageId: Map<string, IReportingRule> = new Map<string, IReportingRule>();
|
||||
|
||||
private _compilerDefaultRule: IReportingRule = {
|
||||
logLevel: ExtractorLogLevel.None,
|
||||
addToApiReportFile: false,
|
||||
};
|
||||
|
||||
private _extractorDefaultRule: IReportingRule = {
|
||||
logLevel: ExtractorLogLevel.None,
|
||||
addToApiReportFile: false,
|
||||
};
|
||||
|
||||
private _tsdocDefaultRule: IReportingRule = { logLevel: ExtractorLogLevel.None, addToApiReportFile: false };
|
||||
|
||||
public errorCount: number = 0;
|
||||
|
||||
public warningCount: number = 0;
|
||||
|
||||
/**
|
||||
* See {@link IExtractorInvokeOptions.showVerboseMessages}
|
||||
*/
|
||||
public readonly showVerboseMessages: boolean;
|
||||
|
||||
/**
|
||||
* See {@link IExtractorInvokeOptions.showDiagnostics}
|
||||
*/
|
||||
public readonly showDiagnostics: boolean;
|
||||
|
||||
public constructor(options: IMessageRouterOptions) {
|
||||
this._workingPackageFolder = options.workingPackageFolder;
|
||||
this._messageCallback = options.messageCallback;
|
||||
|
||||
this._messages = [];
|
||||
this._associatedMessagesForAstDeclaration = new Map<AstDeclaration, ExtractorMessage[]>();
|
||||
this._sourceMapper = options.sourceMapper;
|
||||
this._tsdocConfiguration = options.tsdocConfiguration;
|
||||
|
||||
// showDiagnostics implies showVerboseMessages
|
||||
this.showVerboseMessages = options.showVerboseMessages || options.showDiagnostics;
|
||||
this.showDiagnostics = options.showDiagnostics;
|
||||
|
||||
this._applyMessagesConfig(options.messagesConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the api-extractor.json configuration and build up the tables of routing rules.
|
||||
*/
|
||||
private _applyMessagesConfig(messagesConfig: IExtractorMessagesConfig): void {
|
||||
if (messagesConfig.compilerMessageReporting) {
|
||||
for (const messageId of Object.getOwnPropertyNames(messagesConfig.compilerMessageReporting)) {
|
||||
const reportingRule: IReportingRule = MessageRouter._getNormalizedRule(
|
||||
messagesConfig.compilerMessageReporting[messageId]!,
|
||||
);
|
||||
|
||||
if (messageId === 'default') {
|
||||
this._compilerDefaultRule = reportingRule;
|
||||
} else if (/^TS\d+$/.test(messageId)) {
|
||||
this._reportingRuleByMessageId.set(messageId, reportingRule);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Error in API Extractor config: The messages.compilerMessageReporting table contains` +
|
||||
` an invalid entry "${messageId}". The identifier format is "TS" followed by an integer.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (messagesConfig.extractorMessageReporting) {
|
||||
for (const messageId of Object.getOwnPropertyNames(messagesConfig.extractorMessageReporting)) {
|
||||
const reportingRule: IReportingRule = MessageRouter._getNormalizedRule(
|
||||
messagesConfig.extractorMessageReporting[messageId]!,
|
||||
);
|
||||
|
||||
if (messageId === 'default') {
|
||||
this._extractorDefaultRule = reportingRule;
|
||||
} else if (!messageId.startsWith('ae-')) {
|
||||
throw new Error(
|
||||
`Error in API Extractor config: The messages.extractorMessageReporting table contains` +
|
||||
` an invalid entry "${messageId}". The name should begin with the "ae-" prefix.`,
|
||||
);
|
||||
} else if (allExtractorMessageIds.has(messageId)) {
|
||||
this._reportingRuleByMessageId.set(messageId, reportingRule);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Error in API Extractor config: The messages.extractorMessageReporting table contains` +
|
||||
` an unrecognized identifier "${messageId}". Is it spelled correctly?`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (messagesConfig.tsdocMessageReporting) {
|
||||
for (const messageId of Object.getOwnPropertyNames(messagesConfig.tsdocMessageReporting)) {
|
||||
const reportingRule: IReportingRule = MessageRouter._getNormalizedRule(
|
||||
messagesConfig.tsdocMessageReporting[messageId]!,
|
||||
);
|
||||
|
||||
if (messageId === 'default') {
|
||||
this._tsdocDefaultRule = reportingRule;
|
||||
} else if (!messageId.startsWith('tsdoc-')) {
|
||||
throw new Error(
|
||||
`Error in API Extractor config: The messages.tsdocMessageReporting table contains` +
|
||||
` an invalid entry "${messageId}". The name should begin with the "tsdoc-" prefix.`,
|
||||
);
|
||||
} else if (this._tsdocConfiguration.isKnownMessageId(messageId)) {
|
||||
this._reportingRuleByMessageId.set(messageId, reportingRule);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Error in API Extractor config: The messages.tsdocMessageReporting table contains` +
|
||||
` an unrecognized identifier "${messageId}". Is it spelled correctly?`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static _getNormalizedRule(rule: IConfigMessageReportingRule): IReportingRule {
|
||||
return {
|
||||
logLevel: rule.logLevel || 'none',
|
||||
addToApiReportFile: rule.addToApiReportFile ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
public get messages(): readonly ExtractorMessage[] {
|
||||
return this._messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a diagnostic message reported by the TypeScript compiler
|
||||
*/
|
||||
public addCompilerDiagnostic(diagnostic: ts.Diagnostic): void {
|
||||
switch (diagnostic.category) {
|
||||
case ts.DiagnosticCategory.Suggestion:
|
||||
case ts.DiagnosticCategory.Message:
|
||||
return; // ignore noise
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
const messageText: string = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
|
||||
const options: IExtractorMessageOptions = {
|
||||
category: ExtractorMessageCategory.Compiler,
|
||||
messageId: `TS${diagnostic.code}`,
|
||||
text: messageText,
|
||||
};
|
||||
|
||||
if (diagnostic.file) {
|
||||
// NOTE: Since compiler errors pertain to issues specific to the .d.ts files,
|
||||
// we do not apply source mappings for them.
|
||||
const sourceFile: ts.SourceFile = diagnostic.file;
|
||||
const sourceLocation: ISourceLocation = this._sourceMapper.getSourceLocation({
|
||||
sourceFile,
|
||||
pos: diagnostic.start ?? 0,
|
||||
useDtsLocation: true,
|
||||
});
|
||||
options.sourceFilePath = sourceLocation.sourceFilePath;
|
||||
options.sourceFileLine = sourceLocation.sourceFileLine;
|
||||
options.sourceFileColumn = sourceLocation.sourceFileColumn;
|
||||
}
|
||||
|
||||
this._messages.push(new ExtractorMessage(options));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a message from the API Extractor analysis
|
||||
*/
|
||||
public addAnalyzerIssue(
|
||||
messageId: ExtractorMessageId,
|
||||
messageText: string,
|
||||
astDeclarationOrSymbol: AstDeclaration | AstSymbol,
|
||||
properties?: IExtractorMessageProperties,
|
||||
): void {
|
||||
let astDeclaration: AstDeclaration;
|
||||
if (astDeclarationOrSymbol instanceof AstDeclaration) {
|
||||
astDeclaration = astDeclarationOrSymbol;
|
||||
} else {
|
||||
astDeclaration = astDeclarationOrSymbol.astDeclarations[0]!;
|
||||
}
|
||||
|
||||
const extractorMessage: ExtractorMessage = this.addAnalyzerIssueForPosition(
|
||||
messageId,
|
||||
messageText,
|
||||
astDeclaration.declaration.getSourceFile(),
|
||||
astDeclaration.declaration.getStart(),
|
||||
properties,
|
||||
);
|
||||
|
||||
this._associateMessageWithAstDeclaration(extractorMessage, astDeclaration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add all messages produced from an invocation of the TSDoc parser, assuming they refer to
|
||||
* code in the specified source file.
|
||||
*/
|
||||
public addTsdocMessages(
|
||||
parserContext: tsdoc.ParserContext,
|
||||
sourceFile: ts.SourceFile,
|
||||
astDeclaration?: AstDeclaration,
|
||||
): void {
|
||||
for (const message of parserContext.log.messages) {
|
||||
const options: IExtractorMessageOptions = {
|
||||
category: ExtractorMessageCategory.TSDoc,
|
||||
messageId: message.messageId,
|
||||
text: message.unformattedText,
|
||||
};
|
||||
|
||||
const sourceLocation: ISourceLocation = this._sourceMapper.getSourceLocation({
|
||||
sourceFile,
|
||||
pos: message.textRange.pos,
|
||||
});
|
||||
options.sourceFilePath = sourceLocation.sourceFilePath;
|
||||
options.sourceFileLine = sourceLocation.sourceFileLine;
|
||||
options.sourceFileColumn = sourceLocation.sourceFileColumn;
|
||||
|
||||
const extractorMessage: ExtractorMessage = new ExtractorMessage(options);
|
||||
|
||||
if (astDeclaration) {
|
||||
this._associateMessageWithAstDeclaration(extractorMessage, astDeclaration);
|
||||
}
|
||||
|
||||
this._messages.push(extractorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively collects the primitive members (numbers, strings, arrays, etc) into an object that
|
||||
* is JSON serializable. This is used by the "--diagnostics" feature to dump the state of configuration objects.
|
||||
*
|
||||
* @returns a JSON serializable object (possibly including `null` values)
|
||||
* or `undefined` if the input cannot be represented as JSON
|
||||
*/
|
||||
|
||||
public static buildJsonDumpObject(input: any, options?: IBuildJsonDumpObjectOptions): any | undefined {
|
||||
const ioptions = options ?? {};
|
||||
|
||||
const keyNamesToOmit: Set<string> = new Set(ioptions.keyNamesToOmit);
|
||||
|
||||
return MessageRouter._buildJsonDumpObject(input, keyNamesToOmit);
|
||||
}
|
||||
|
||||
private static _buildJsonDumpObject(input: any, keyNamesToOmit: Set<string>): any | undefined {
|
||||
if (input === null || input === undefined) {
|
||||
return null; // JSON uses null instead of undefined
|
||||
}
|
||||
|
||||
switch (typeof input) {
|
||||
case 'boolean':
|
||||
case 'number':
|
||||
case 'string':
|
||||
return input;
|
||||
case 'object':
|
||||
if (Array.isArray(input)) {
|
||||
const outputArray: any[] = [];
|
||||
for (const element of input) {
|
||||
const serializedElement: any = MessageRouter._buildJsonDumpObject(element, keyNamesToOmit);
|
||||
if (serializedElement !== undefined) {
|
||||
outputArray.push(serializedElement);
|
||||
}
|
||||
}
|
||||
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-case-declarations
|
||||
const outputObject: object = {};
|
||||
for (const key of Object.getOwnPropertyNames(input)) {
|
||||
if (keyNamesToOmit.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value: any = input[key];
|
||||
|
||||
const serializedValue: any = MessageRouter._buildJsonDumpObject(value, keyNamesToOmit);
|
||||
|
||||
if (serializedValue !== undefined) {
|
||||
(outputObject as any)[key] = serializedValue;
|
||||
}
|
||||
}
|
||||
|
||||
return outputObject;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record this message in _associatedMessagesForAstDeclaration
|
||||
*/
|
||||
private _associateMessageWithAstDeclaration(
|
||||
extractorMessage: ExtractorMessage,
|
||||
astDeclaration: AstDeclaration,
|
||||
): void {
|
||||
let associatedMessages: ExtractorMessage[] | undefined =
|
||||
this._associatedMessagesForAstDeclaration.get(astDeclaration);
|
||||
|
||||
if (!associatedMessages) {
|
||||
associatedMessages = [];
|
||||
this._associatedMessagesForAstDeclaration.set(astDeclaration, associatedMessages);
|
||||
}
|
||||
|
||||
associatedMessages.push(extractorMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a message for a location in an arbitrary source file.
|
||||
*/
|
||||
public addAnalyzerIssueForPosition(
|
||||
messageId: ExtractorMessageId,
|
||||
messageText: string,
|
||||
sourceFile: ts.SourceFile,
|
||||
pos: number,
|
||||
properties?: IExtractorMessageProperties,
|
||||
): ExtractorMessage {
|
||||
const options: IExtractorMessageOptions = {
|
||||
category: ExtractorMessageCategory.Extractor,
|
||||
messageId,
|
||||
text: messageText,
|
||||
properties,
|
||||
};
|
||||
|
||||
const sourceLocation: ISourceLocation = this._sourceMapper.getSourceLocation({
|
||||
sourceFile,
|
||||
pos,
|
||||
});
|
||||
options.sourceFilePath = sourceLocation.sourceFilePath;
|
||||
options.sourceFileLine = sourceLocation.sourceFileLine;
|
||||
options.sourceFileColumn = sourceLocation.sourceFileColumn;
|
||||
|
||||
const extractorMessage: ExtractorMessage = new ExtractorMessage(options);
|
||||
|
||||
this._messages.push(extractorMessage);
|
||||
return extractorMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is used when writing the API report file. It looks up any messages that were configured to get emitted
|
||||
* in the API report file and returns them. It also records that they were emitted, which suppresses them from
|
||||
* being shown on the console.
|
||||
*/
|
||||
public fetchAssociatedMessagesForReviewFile(astDeclaration: AstDeclaration): ExtractorMessage[] {
|
||||
const messagesForApiReportFile: ExtractorMessage[] = [];
|
||||
|
||||
const associatedMessages: ExtractorMessage[] = this._associatedMessagesForAstDeclaration.get(astDeclaration) ?? [];
|
||||
for (const associatedMessage of associatedMessages) {
|
||||
// Make sure we didn't already report this message for some reason
|
||||
if (!associatedMessage.handled) {
|
||||
// Is this message type configured to go in the API report file?
|
||||
const reportingRule: IReportingRule = this._getRuleForMessage(associatedMessage);
|
||||
if (reportingRule.addToApiReportFile) {
|
||||
// Include it in the result, and record that it went to the API report file
|
||||
messagesForApiReportFile.push(associatedMessage);
|
||||
associatedMessage.handled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this._sortMessagesForOutput(messagesForApiReportFile);
|
||||
return messagesForApiReportFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* This returns all remaining messages that were flagged with `addToApiReportFile`, but which were not
|
||||
* retreieved using `fetchAssociatedMessagesForReviewFile()`.
|
||||
*/
|
||||
public fetchUnassociatedMessagesForReviewFile(): ExtractorMessage[] {
|
||||
const messagesForApiReportFile: ExtractorMessage[] = [];
|
||||
|
||||
for (const unassociatedMessage of this.messages) {
|
||||
// Make sure we didn't already report this message for some reason
|
||||
if (!unassociatedMessage.handled) {
|
||||
// Is this message type configured to go in the API report file?
|
||||
const reportingRule: IReportingRule = this._getRuleForMessage(unassociatedMessage);
|
||||
if (reportingRule.addToApiReportFile) {
|
||||
// Include it in the result, and record that it went to the API report file
|
||||
messagesForApiReportFile.push(unassociatedMessage);
|
||||
unassociatedMessage.handled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this._sortMessagesForOutput(messagesForApiReportFile);
|
||||
return messagesForApiReportFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* This returns the list of remaining messages that were not already processed by
|
||||
* `fetchAssociatedMessagesForReviewFile()` or `fetchUnassociatedMessagesForReviewFile()`.
|
||||
* These messages will be shown on the console.
|
||||
*/
|
||||
public handleRemainingNonConsoleMessages(): void {
|
||||
const messagesForLogger: ExtractorMessage[] = [];
|
||||
|
||||
for (const message of this.messages) {
|
||||
// Make sure we didn't already report this message
|
||||
if (!message.handled) {
|
||||
messagesForLogger.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
this._sortMessagesForOutput(messagesForLogger);
|
||||
|
||||
for (const message of messagesForLogger) {
|
||||
this._handleMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
public logError(messageId: ConsoleMessageId, message: string, properties?: IExtractorMessageProperties): void {
|
||||
this._handleMessage(
|
||||
new ExtractorMessage({
|
||||
category: ExtractorMessageCategory.Console,
|
||||
messageId,
|
||||
text: message,
|
||||
properties,
|
||||
logLevel: ExtractorLogLevel.Error,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public logWarning(messageId: ConsoleMessageId, message: string, properties?: IExtractorMessageProperties): void {
|
||||
this._handleMessage(
|
||||
new ExtractorMessage({
|
||||
category: ExtractorMessageCategory.Console,
|
||||
messageId,
|
||||
text: message,
|
||||
properties,
|
||||
logLevel: ExtractorLogLevel.Warning,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public logInfo(messageId: ConsoleMessageId, message: string, properties?: IExtractorMessageProperties): void {
|
||||
this._handleMessage(
|
||||
new ExtractorMessage({
|
||||
category: ExtractorMessageCategory.Console,
|
||||
messageId,
|
||||
text: message,
|
||||
properties,
|
||||
logLevel: ExtractorLogLevel.Info,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public logVerbose(messageId: ConsoleMessageId, message: string, properties?: IExtractorMessageProperties): void {
|
||||
this._handleMessage(
|
||||
new ExtractorMessage({
|
||||
category: ExtractorMessageCategory.Console,
|
||||
messageId,
|
||||
text: message,
|
||||
properties,
|
||||
logLevel: ExtractorLogLevel.Verbose,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public logDiagnosticHeader(title: string): void {
|
||||
this.logDiagnostic(MessageRouter.DIAGNOSTICS_LINE);
|
||||
this.logDiagnostic(`DIAGNOSTIC: ` + title);
|
||||
this.logDiagnostic(MessageRouter.DIAGNOSTICS_LINE);
|
||||
}
|
||||
|
||||
public logDiagnosticFooter(): void {
|
||||
this.logDiagnostic(MessageRouter.DIAGNOSTICS_LINE + '\n');
|
||||
}
|
||||
|
||||
public logDiagnostic(message: string): void {
|
||||
if (this.showDiagnostics) {
|
||||
this.logVerbose(ConsoleMessageId.Diagnostics, message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Give the calling application a chance to handle the `ExtractorMessage`, and if not, display it on the console.
|
||||
*/
|
||||
private _handleMessage(message: ExtractorMessage): void {
|
||||
// Don't tally messages that were already "handled" by writing them into the API report
|
||||
if (message.handled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Assign the ExtractorMessage.logLevel; the message callback may adjust it below
|
||||
if (message.category === ExtractorMessageCategory.Console) {
|
||||
// Console messages have their category log level assigned via logInfo(), logVerbose(), etc.
|
||||
} else {
|
||||
const reportingRule: IReportingRule = this._getRuleForMessage(message);
|
||||
message.logLevel = reportingRule.logLevel;
|
||||
}
|
||||
|
||||
// If there is a callback, allow it to modify and/or handle the message
|
||||
if (this._messageCallback) {
|
||||
this._messageCallback(message);
|
||||
}
|
||||
|
||||
// Update the statistics
|
||||
switch (message.logLevel) {
|
||||
case ExtractorLogLevel.Error:
|
||||
++this.errorCount;
|
||||
break;
|
||||
case ExtractorLogLevel.Warning:
|
||||
++this.warningCount;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (message.handled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The messageCallback did not handle the message, so perform default handling
|
||||
message.handled = true;
|
||||
|
||||
if (message.logLevel === ExtractorLogLevel.None) {
|
||||
return;
|
||||
}
|
||||
|
||||
let messageText: string;
|
||||
if (message.category === ExtractorMessageCategory.Console) {
|
||||
messageText = message.text;
|
||||
} else {
|
||||
messageText = message.formatMessageWithLocation(this._workingPackageFolder);
|
||||
}
|
||||
|
||||
switch (message.logLevel) {
|
||||
case ExtractorLogLevel.Error:
|
||||
console.error(colors.red('Error: ' + messageText));
|
||||
break;
|
||||
case ExtractorLogLevel.Warning:
|
||||
console.warn(colors.yellow('Warning: ' + messageText));
|
||||
break;
|
||||
case ExtractorLogLevel.Info:
|
||||
console.log(messageText);
|
||||
break;
|
||||
case ExtractorLogLevel.Verbose:
|
||||
if (this.showVerboseMessages) {
|
||||
console.log(colors.cyan(messageText));
|
||||
}
|
||||
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Invalid logLevel value: ${JSON.stringify(message.logLevel)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For a given message, determine the IReportingRule based on the rule tables.
|
||||
*/
|
||||
private _getRuleForMessage(message: ExtractorMessage): IReportingRule {
|
||||
const reportingRule: IReportingRule | undefined = this._reportingRuleByMessageId.get(message.messageId);
|
||||
if (reportingRule) {
|
||||
return reportingRule;
|
||||
}
|
||||
|
||||
switch (message.category) {
|
||||
case ExtractorMessageCategory.Compiler:
|
||||
return this._compilerDefaultRule;
|
||||
case ExtractorMessageCategory.Extractor:
|
||||
return this._extractorDefaultRule;
|
||||
case ExtractorMessageCategory.TSDoc:
|
||||
return this._tsdocDefaultRule;
|
||||
case ExtractorMessageCategory.Console:
|
||||
throw new InternalError('ExtractorMessageCategory.Console is not supported with IReportingRule');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts an array of messages according to a reasonable ordering
|
||||
*/
|
||||
private _sortMessagesForOutput(messages: ExtractorMessage[]): void {
|
||||
messages.sort((a, b) => {
|
||||
let diff: number;
|
||||
// First sort by file name
|
||||
diff = Sort.compareByValue(a.sourceFilePath, b.sourceFilePath);
|
||||
if (diff !== 0) {
|
||||
return diff;
|
||||
}
|
||||
|
||||
// Then sort by line number
|
||||
diff = Sort.compareByValue(a.sourceFileLine, b.sourceFileLine);
|
||||
if (diff !== 0) {
|
||||
return diff;
|
||||
}
|
||||
|
||||
// Then sort by messageId
|
||||
return Sort.compareByValue(a.messageId, b.messageId);
|
||||
});
|
||||
}
|
||||
}
|
||||
258
packages/api-extractor/src/collector/SourceMapper.ts
Normal file
258
packages/api-extractor/src/collector/SourceMapper.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
// 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, InternalError, JsonFile, NewlineKind } from '@rushstack/node-core-library';
|
||||
import { SourceMapConsumer, type RawSourceMap, type MappingItem, type Position } from 'source-map';
|
||||
import type ts from 'typescript';
|
||||
|
||||
interface ISourceMap {
|
||||
// SourceMapConsumer.originalPositionFor() is useless because the mapping contains numerous gaps,
|
||||
// and the API provides no way to find the nearest match. So instead we extract all the mapping items
|
||||
// and search them using SourceMapper._findNearestMappingItem().
|
||||
mappingItems: MappingItem[];
|
||||
|
||||
sourceMapConsumer: SourceMapConsumer;
|
||||
}
|
||||
|
||||
interface IOriginalFileInfo {
|
||||
// Whether the .ts file exists
|
||||
fileExists: boolean;
|
||||
|
||||
// This is used to check whether the guessed position is out of bounds.
|
||||
// Since column/line numbers are 1-based, the 0th item in this array is unused.
|
||||
maxColumnForLine: number[];
|
||||
}
|
||||
|
||||
export interface ISourceLocation {
|
||||
/**
|
||||
* The column number in the source file. The first column number is 1.
|
||||
*/
|
||||
sourceFileColumn: number;
|
||||
|
||||
/**
|
||||
* The line number in the source file. The first line number is 1.
|
||||
*/
|
||||
sourceFileLine: number;
|
||||
|
||||
/**
|
||||
* The absolute path to the source file.
|
||||
*/
|
||||
sourceFilePath: string;
|
||||
}
|
||||
|
||||
export interface IGetSourceLocationOptions {
|
||||
/**
|
||||
* The position within the source file to get the source location from.
|
||||
*/
|
||||
pos: number;
|
||||
|
||||
/**
|
||||
* The source file to get the source location from.
|
||||
*/
|
||||
sourceFile: ts.SourceFile;
|
||||
|
||||
/**
|
||||
* If `false` or not provided, then we attempt to follow source maps in order to resolve the
|
||||
* location to the original `.ts` file. If resolution isn't possible for some reason, we fall
|
||||
* back to the `.d.ts` location.
|
||||
*
|
||||
* If `true`, then we don't bother following source maps, and the location refers to the `.d.ts`
|
||||
* location.
|
||||
*/
|
||||
useDtsLocation?: boolean;
|
||||
}
|
||||
|
||||
export class SourceMapper {
|
||||
// Map from .d.ts file path --> ISourceMap if a source map was found, or null if not found
|
||||
private _sourceMapByFilePath: Map<string, ISourceMap | null> = new Map<string, ISourceMap | null>();
|
||||
|
||||
// Cache the FileSystem.exists() result for mapped .ts files
|
||||
private _originalFileInfoByPath: Map<string, IOriginalFileInfo> = new Map<string, IOriginalFileInfo>();
|
||||
|
||||
/**
|
||||
* Given a `.d.ts` source file and a specific position within the file, return the corresponding
|
||||
* `ISourceLocation`.
|
||||
*/
|
||||
public getSourceLocation(options: IGetSourceLocationOptions): ISourceLocation {
|
||||
const lineAndCharacter: ts.LineAndCharacter = options.sourceFile.getLineAndCharacterOfPosition(options.pos);
|
||||
const sourceLocation: ISourceLocation = {
|
||||
sourceFilePath: options.sourceFile.fileName,
|
||||
sourceFileLine: lineAndCharacter.line + 1,
|
||||
sourceFileColumn: lineAndCharacter.character + 1,
|
||||
};
|
||||
|
||||
if (options.useDtsLocation) {
|
||||
return sourceLocation;
|
||||
}
|
||||
|
||||
const mappedSourceLocation: ISourceLocation | undefined = this._getMappedSourceLocation(sourceLocation);
|
||||
return mappedSourceLocation ?? sourceLocation;
|
||||
}
|
||||
|
||||
private _getMappedSourceLocation(sourceLocation: ISourceLocation): ISourceLocation | undefined {
|
||||
const { sourceFilePath, sourceFileLine, sourceFileColumn } = sourceLocation;
|
||||
|
||||
if (!FileSystem.exists(sourceFilePath)) {
|
||||
// Sanity check
|
||||
throw new InternalError('The referenced path was not found: ' + sourceFilePath);
|
||||
}
|
||||
|
||||
const sourceMap: ISourceMap | null = this._getSourceMap(sourceFilePath);
|
||||
if (!sourceMap) return;
|
||||
|
||||
const nearestMappingItem: MappingItem | undefined = SourceMapper._findNearestMappingItem(sourceMap.mappingItems, {
|
||||
line: sourceFileLine,
|
||||
column: sourceFileColumn,
|
||||
});
|
||||
|
||||
if (!nearestMappingItem) return;
|
||||
|
||||
const mappedFilePath: string = path.resolve(path.dirname(sourceFilePath), nearestMappingItem.source);
|
||||
|
||||
// Does the mapped filename exist? Use a cache to remember the answer.
|
||||
let originalFileInfo: IOriginalFileInfo | undefined = this._originalFileInfoByPath.get(mappedFilePath);
|
||||
if (originalFileInfo === undefined) {
|
||||
originalFileInfo = {
|
||||
fileExists: FileSystem.exists(mappedFilePath),
|
||||
maxColumnForLine: [],
|
||||
};
|
||||
|
||||
if (originalFileInfo.fileExists) {
|
||||
// Read the file and measure the length of each line
|
||||
originalFileInfo.maxColumnForLine = FileSystem.readFile(mappedFilePath, {
|
||||
convertLineEndings: NewlineKind.Lf,
|
||||
})
|
||||
.split('\n')
|
||||
.map((x) => x.length + 1); // +1 since columns are 1-based
|
||||
originalFileInfo.maxColumnForLine.unshift(0); // Extra item since lines are 1-based
|
||||
}
|
||||
|
||||
this._originalFileInfoByPath.set(mappedFilePath, originalFileInfo);
|
||||
}
|
||||
|
||||
// Don't translate coordinates to a file that doesn't exist
|
||||
if (!originalFileInfo.fileExists) return;
|
||||
|
||||
// The nearestMappingItem anchor may be above/left of the real position, due to gaps in the mapping. Calculate
|
||||
// the delta and apply it to the original position.
|
||||
const guessedPosition: Position = {
|
||||
line: nearestMappingItem.originalLine + sourceFileLine - nearestMappingItem.generatedLine,
|
||||
column: nearestMappingItem.originalColumn + sourceFileColumn - nearestMappingItem.generatedColumn,
|
||||
};
|
||||
|
||||
// Verify that the result is not out of bounds, in cause our heuristic failed
|
||||
if (
|
||||
guessedPosition.line >= 1 &&
|
||||
guessedPosition.line < originalFileInfo.maxColumnForLine.length &&
|
||||
guessedPosition.column >= 1 &&
|
||||
guessedPosition.column <= originalFileInfo.maxColumnForLine[guessedPosition.line]!
|
||||
) {
|
||||
return {
|
||||
sourceFilePath: mappedFilePath,
|
||||
sourceFileLine: guessedPosition.line,
|
||||
sourceFileColumn: guessedPosition.column,
|
||||
};
|
||||
} else {
|
||||
// The guessed position was out of bounds, so use the nearestMappingItem position instead.
|
||||
return {
|
||||
sourceFilePath: mappedFilePath,
|
||||
sourceFileLine: nearestMappingItem.originalLine,
|
||||
sourceFileColumn: nearestMappingItem.originalColumn,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private _getSourceMap(sourceFilePath: string): ISourceMap | null {
|
||||
let sourceMap: ISourceMap | null | undefined = this._sourceMapByFilePath.get(sourceFilePath);
|
||||
|
||||
if (sourceMap === undefined) {
|
||||
// Normalize the path and redo the lookup
|
||||
const normalizedPath: string = FileSystem.getRealPath(sourceFilePath);
|
||||
|
||||
sourceMap = this._sourceMapByFilePath.get(normalizedPath);
|
||||
if (sourceMap === undefined) {
|
||||
// Given "folder/file.d.ts", check for a corresponding "folder/file.d.ts.map"
|
||||
const sourceMapPath: string = normalizedPath + '.map';
|
||||
if (FileSystem.exists(sourceMapPath)) {
|
||||
// Load up the source map
|
||||
const rawSourceMap: RawSourceMap = JsonFile.load(sourceMapPath) as RawSourceMap;
|
||||
|
||||
const sourceMapConsumer: SourceMapConsumer = new SourceMapConsumer(rawSourceMap);
|
||||
const mappingItems: MappingItem[] = [];
|
||||
|
||||
// Extract the list of mapping items
|
||||
sourceMapConsumer.eachMapping(
|
||||
(mappingItem: MappingItem) => {
|
||||
mappingItems.push({
|
||||
...mappingItem,
|
||||
// The "source-map" package inexplicably uses 1-based line numbers but 0-based column numbers.
|
||||
// Fix that up proactively so we don't have to deal with it later.
|
||||
generatedColumn: mappingItem.generatedColumn + 1,
|
||||
originalColumn: mappingItem.originalColumn + 1,
|
||||
});
|
||||
},
|
||||
this,
|
||||
SourceMapConsumer.GENERATED_ORDER,
|
||||
);
|
||||
|
||||
sourceMap = { sourceMapConsumer, mappingItems };
|
||||
} else {
|
||||
// No source map for this filename
|
||||
sourceMap = null;
|
||||
}
|
||||
|
||||
this._sourceMapByFilePath.set(normalizedPath, sourceMap);
|
||||
if (sourceFilePath !== normalizedPath) {
|
||||
// Add both keys to the map
|
||||
this._sourceMapByFilePath.set(sourceFilePath, sourceMap);
|
||||
}
|
||||
} else {
|
||||
// Copy the result from the normalized to the non-normalized key
|
||||
this._sourceMapByFilePath.set(sourceFilePath, sourceMap);
|
||||
}
|
||||
}
|
||||
|
||||
return sourceMap;
|
||||
}
|
||||
|
||||
// The `mappingItems` array is sorted by generatedLine/generatedColumn (GENERATED_ORDER).
|
||||
// The _findNearestMappingItem() lookup is a simple binary search that returns the previous item
|
||||
// if there is no exact match.
|
||||
private static _findNearestMappingItem(mappingItems: MappingItem[], position: Position): MappingItem | undefined {
|
||||
if (mappingItems.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let startIndex = 0;
|
||||
let endIndex: number = mappingItems.length - 1;
|
||||
|
||||
while (startIndex <= endIndex) {
|
||||
const middleIndex: number = startIndex + Math.floor((endIndex - startIndex) / 2);
|
||||
|
||||
const diff: number = SourceMapper._compareMappingItem(mappingItems[middleIndex]!, position);
|
||||
|
||||
if (diff < 0) {
|
||||
startIndex = middleIndex + 1;
|
||||
} else if (diff > 0) {
|
||||
endIndex = middleIndex - 1;
|
||||
} else {
|
||||
// Exact match
|
||||
return mappingItems[middleIndex];
|
||||
}
|
||||
}
|
||||
|
||||
// If we didn't find an exact match, then endIndex < startIndex.
|
||||
// Take endIndex because it's the smaller value.
|
||||
return mappingItems[endIndex];
|
||||
}
|
||||
|
||||
private static _compareMappingItem(mappingItem: MappingItem, position: Position): number {
|
||||
const diff: number = mappingItem.generatedLine - position.line;
|
||||
if (diff !== 0) {
|
||||
return diff;
|
||||
}
|
||||
|
||||
return mappingItem.generatedColumn - position.column;
|
||||
}
|
||||
}
|
||||
25
packages/api-extractor/src/collector/SymbolMetadata.ts
Normal file
25
packages/api-extractor/src/collector/SymbolMetadata.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
import type { ReleaseTag } from '@discordjs/api-extractor-model';
|
||||
|
||||
/**
|
||||
* Constructor parameters for `SymbolMetadata`.
|
||||
*/
|
||||
export interface ISymbolMetadataOptions {
|
||||
maxEffectiveReleaseTag: ReleaseTag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the Collector's additional analysis for an `AstSymbol`. This object is assigned to `AstSymbol.metadata`
|
||||
* but consumers must always obtain it by calling `Collector.fetchSymbolMetadata()`.
|
||||
*/
|
||||
export class SymbolMetadata {
|
||||
// For all declarations associated with this symbol, this is the
|
||||
// `ApiItemMetadata.effectiveReleaseTag` value that is most public.
|
||||
public readonly maxEffectiveReleaseTag: ReleaseTag;
|
||||
|
||||
public constructor(options: ISymbolMetadataOptions) {
|
||||
this.maxEffectiveReleaseTag = options.maxEffectiveReleaseTag;
|
||||
}
|
||||
}
|
||||
24
packages/api-extractor/src/collector/VisitorState.ts
Normal file
24
packages/api-extractor/src/collector/VisitorState.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
/**
|
||||
* Keeps track of a directed graph traversal that needs to detect cycles.
|
||||
*/
|
||||
export enum VisitorState {
|
||||
/**
|
||||
* We have not visited the node yet.
|
||||
*/
|
||||
Unvisited = 0,
|
||||
|
||||
/**
|
||||
* We have visited the node, but have not finished traversing its references yet.
|
||||
* If we reach a node that is already in the `Visiting` state, this means we have
|
||||
* encountered a cyclic reference.
|
||||
*/
|
||||
Visiting = 1,
|
||||
|
||||
/**
|
||||
* We are finished vising the node and all its references.
|
||||
*/
|
||||
Visited = 2,
|
||||
}
|
||||
78
packages/api-extractor/src/collector/WorkingPackage.ts
Normal file
78
packages/api-extractor/src/collector/WorkingPackage.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
import type * as tsdoc from '@microsoft/tsdoc';
|
||||
import type { INodePackageJson } from '@rushstack/node-core-library';
|
||||
import type * as ts from 'typescript';
|
||||
|
||||
/**
|
||||
* Constructor options for WorkingPackage
|
||||
*/
|
||||
export interface IWorkingPackageOptions {
|
||||
entryPointSourceFile: ts.SourceFile;
|
||||
packageFolder: string;
|
||||
packageJson: INodePackageJson;
|
||||
}
|
||||
|
||||
/**
|
||||
* Information about the working package for a particular invocation of API Extractor.
|
||||
*
|
||||
* @remarks
|
||||
* API Extractor tries to model the world as a collection of NPM packages, such that each
|
||||
* .d.ts file belongs to at most one package. When API Extractor is invoked on a project,
|
||||
* we refer to that project as being the "working package". There is exactly one
|
||||
* "working package" for the duration of this analysis. Any files that do not belong to
|
||||
* the working package are referred to as "external": external declarations belonging to
|
||||
* external packages.
|
||||
*
|
||||
* If API Extractor is invoked on a standalone .d.ts file, the "working package" may not
|
||||
* have an actual package.json file on disk, but we still refer to it in concept.
|
||||
*/
|
||||
export class WorkingPackage {
|
||||
/**
|
||||
* Returns the folder for the package.json file of the working package.
|
||||
*
|
||||
* @remarks
|
||||
* If the entry point is `C:\Folder\project\src\index.ts` and the nearest package.json
|
||||
* is `C:\Folder\project\package.json`, then the packageFolder is `C:\Folder\project`
|
||||
*/
|
||||
public readonly packageFolder: string;
|
||||
|
||||
/**
|
||||
* The parsed package.json file for the working package.
|
||||
*/
|
||||
public readonly packageJson: INodePackageJson;
|
||||
|
||||
/**
|
||||
* The entry point being processed during this invocation of API Extractor.
|
||||
*
|
||||
* @remarks
|
||||
* The working package may have multiple entry points; however, today API Extractor
|
||||
* only processes a single entry point during an invocation. This will be improved
|
||||
* in the future.
|
||||
*/
|
||||
public readonly entryPointSourceFile: ts.SourceFile;
|
||||
|
||||
/**
|
||||
* The `@packageDocumentation` comment, if any, for the working package.
|
||||
*/
|
||||
public tsdocComment: tsdoc.DocComment | undefined;
|
||||
|
||||
/**
|
||||
* Additional parser information for `WorkingPackage.tsdocComment`.
|
||||
*/
|
||||
public tsdocParserContext: tsdoc.ParserContext | undefined;
|
||||
|
||||
public constructor(options: IWorkingPackageOptions) {
|
||||
this.packageFolder = options.packageFolder;
|
||||
this.packageJson = options.packageJson;
|
||||
this.entryPointSourceFile = options.entryPointSourceFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the full name of the working package.
|
||||
*/
|
||||
public get name(): string {
|
||||
return this.packageJson.name;
|
||||
}
|
||||
}
|
||||
251
packages/api-extractor/src/enhancers/DocCommentEnhancer.ts
Normal file
251
packages/api-extractor/src/enhancers/DocCommentEnhancer.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
import { ReleaseTag } from '@discordjs/api-extractor-model';
|
||||
import * as tsdoc from '@microsoft/tsdoc';
|
||||
import * as ts from 'typescript';
|
||||
import type { AstDeclaration } from '../analyzer/AstDeclaration.js';
|
||||
import { ResolverFailure } from '../analyzer/AstReferenceResolver.js';
|
||||
import { AstSymbol } from '../analyzer/AstSymbol.js';
|
||||
import { ExtractorMessageId } from '../api/ExtractorMessageId.js';
|
||||
import type { ApiItemMetadata } from '../collector/ApiItemMetadata.js';
|
||||
import type { Collector } from '../collector/Collector.js';
|
||||
import { VisitorState } from '../collector/VisitorState.js';
|
||||
|
||||
export class DocCommentEnhancer {
|
||||
private readonly _collector: Collector;
|
||||
|
||||
public constructor(collector: Collector) {
|
||||
this._collector = collector;
|
||||
}
|
||||
|
||||
public static analyze(collector: Collector): void {
|
||||
const docCommentEnhancer: DocCommentEnhancer = new DocCommentEnhancer(collector);
|
||||
docCommentEnhancer.analyze();
|
||||
}
|
||||
|
||||
public analyze(): void {
|
||||
for (const entity of this._collector.entities) {
|
||||
if (
|
||||
entity.astEntity instanceof AstSymbol &&
|
||||
(entity.consumable ||
|
||||
this._collector.extractorConfig.apiReportIncludeForgottenExports ||
|
||||
this._collector.extractorConfig.docModelIncludeForgottenExports)
|
||||
) {
|
||||
entity.astEntity.forEachDeclarationRecursive((astDeclaration: AstDeclaration) => {
|
||||
this._analyzeApiItem(astDeclaration);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _analyzeApiItem(astDeclaration: AstDeclaration): void {
|
||||
const metadata: ApiItemMetadata = this._collector.fetchApiItemMetadata(astDeclaration);
|
||||
if (metadata.docCommentEnhancerVisitorState === VisitorState.Visited) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (metadata.docCommentEnhancerVisitorState === VisitorState.Visiting) {
|
||||
this._collector.messageRouter.addAnalyzerIssue(
|
||||
ExtractorMessageId.CyclicInheritDoc,
|
||||
`The @inheritDoc tag for "${astDeclaration.astSymbol.localName}" refers to its own declaration`,
|
||||
astDeclaration,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
metadata.docCommentEnhancerVisitorState = VisitorState.Visiting;
|
||||
|
||||
if (metadata.tsdocComment?.inheritDocTag) {
|
||||
this._applyInheritDoc(astDeclaration, metadata.tsdocComment, metadata.tsdocComment.inheritDocTag);
|
||||
}
|
||||
|
||||
this._analyzeNeedsDocumentation(astDeclaration, metadata);
|
||||
|
||||
this._checkForBrokenLinks(astDeclaration, metadata);
|
||||
|
||||
metadata.docCommentEnhancerVisitorState = VisitorState.Visited;
|
||||
}
|
||||
|
||||
private _analyzeNeedsDocumentation(astDeclaration: AstDeclaration, metadata: ApiItemMetadata): void {
|
||||
if (astDeclaration.declaration.kind === ts.SyntaxKind.Constructor) {
|
||||
// Constructors always do pretty much the same thing, so it's annoying to require people to write
|
||||
// descriptions for them. Instead, if the constructor lacks a TSDoc summary, then API Extractor
|
||||
// will auto-generate one.
|
||||
metadata.undocumented = false;
|
||||
|
||||
// The class that contains this constructor
|
||||
const classDeclaration: AstDeclaration = astDeclaration.parent!;
|
||||
|
||||
const configuration: tsdoc.TSDocConfiguration = this._collector.extractorConfig.tsdocConfiguration;
|
||||
|
||||
if (!metadata.tsdocComment) {
|
||||
metadata.tsdocComment = new tsdoc.DocComment({ configuration });
|
||||
}
|
||||
|
||||
if (!tsdoc.PlainTextEmitter.hasAnyTextContent(metadata.tsdocComment.summarySection)) {
|
||||
metadata.tsdocComment.summarySection.appendNodesInParagraph([
|
||||
new tsdoc.DocPlainText({ configuration, text: 'Constructs a new instance of the ' }),
|
||||
new tsdoc.DocCodeSpan({
|
||||
configuration,
|
||||
code: classDeclaration.astSymbol.localName,
|
||||
}),
|
||||
new tsdoc.DocPlainText({ configuration, text: ' class' }),
|
||||
]);
|
||||
}
|
||||
|
||||
const apiItemMetadata: ApiItemMetadata = this._collector.fetchApiItemMetadata(astDeclaration);
|
||||
if (apiItemMetadata.effectiveReleaseTag === ReleaseTag.Internal) {
|
||||
// If the constructor is marked as internal, then add a boilerplate notice for the containing class
|
||||
const classMetadata: ApiItemMetadata = this._collector.fetchApiItemMetadata(classDeclaration);
|
||||
|
||||
if (!classMetadata.tsdocComment) {
|
||||
classMetadata.tsdocComment = new tsdoc.DocComment({ configuration });
|
||||
}
|
||||
|
||||
if (classMetadata.tsdocComment.remarksBlock === undefined) {
|
||||
classMetadata.tsdocComment.remarksBlock = new tsdoc.DocBlock({
|
||||
configuration,
|
||||
blockTag: new tsdoc.DocBlockTag({
|
||||
configuration,
|
||||
tagName: tsdoc.StandardTags.remarks.tagName,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
classMetadata.tsdocComment.remarksBlock.content.appendNode(
|
||||
new tsdoc.DocParagraph({ configuration }, [
|
||||
new tsdoc.DocPlainText({
|
||||
configuration,
|
||||
text:
|
||||
`The constructor for this class is marked as internal. Third-party code should not` +
|
||||
` call the constructor directly or create subclasses that extend the `,
|
||||
}),
|
||||
new tsdoc.DocCodeSpan({
|
||||
configuration,
|
||||
code: classDeclaration.astSymbol.localName,
|
||||
}),
|
||||
new tsdoc.DocPlainText({ configuration, text: ' class.' }),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (metadata.tsdocComment) {
|
||||
// Require the summary to contain at least 10 non-spacing characters
|
||||
metadata.undocumented = !tsdoc.PlainTextEmitter.hasAnyTextContent(metadata.tsdocComment.summarySection, 10);
|
||||
} else {
|
||||
metadata.undocumented = true;
|
||||
}
|
||||
}
|
||||
|
||||
private _checkForBrokenLinks(astDeclaration: AstDeclaration, metadata: ApiItemMetadata): void {
|
||||
if (!metadata.tsdocComment) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._checkForBrokenLinksRecursive(astDeclaration, metadata.tsdocComment);
|
||||
}
|
||||
|
||||
private _checkForBrokenLinksRecursive(astDeclaration: AstDeclaration, node: tsdoc.DocNode): void {
|
||||
if (
|
||||
node instanceof tsdoc.DocLinkTag &&
|
||||
node.codeDestination && // Is it referring to the working package? If not, we don't do any link validation, because
|
||||
// AstReferenceResolver doesn't support it yet (but ModelReferenceResolver does of course).
|
||||
// Tracked by: https://github.com/microsoft/rushstack/issues/1195
|
||||
(node.codeDestination.packageName === undefined ||
|
||||
node.codeDestination.packageName === this._collector.workingPackage.name)
|
||||
) {
|
||||
const referencedAstDeclaration: AstDeclaration | ResolverFailure = this._collector.astReferenceResolver.resolve(
|
||||
node.codeDestination,
|
||||
);
|
||||
|
||||
if (referencedAstDeclaration instanceof ResolverFailure) {
|
||||
this._collector.messageRouter.addAnalyzerIssue(
|
||||
ExtractorMessageId.UnresolvedLink,
|
||||
'The @link reference could not be resolved: ' + referencedAstDeclaration.reason,
|
||||
astDeclaration,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const childNode of node.getChildNodes()) {
|
||||
this._checkForBrokenLinksRecursive(astDeclaration, childNode);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow an `{@inheritDoc ___}` reference and copy the content that we find in the referenced comment.
|
||||
*/
|
||||
private _applyInheritDoc(
|
||||
astDeclaration: AstDeclaration,
|
||||
docComment: tsdoc.DocComment,
|
||||
inheritDocTag: tsdoc.DocInheritDocTag,
|
||||
): void {
|
||||
if (!inheritDocTag.declarationReference) {
|
||||
this._collector.messageRouter.addAnalyzerIssue(
|
||||
ExtractorMessageId.UnresolvedInheritDocBase,
|
||||
'The @inheritDoc tag needs a TSDoc declaration reference; signature matching is not supported yet',
|
||||
astDeclaration,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Is it referring to the working package?
|
||||
if (
|
||||
!(
|
||||
inheritDocTag.declarationReference.packageName === undefined ||
|
||||
inheritDocTag.declarationReference.packageName === this._collector.workingPackage.name
|
||||
)
|
||||
) {
|
||||
// It's referencing an external package, so skip this inheritDoc tag, since AstReferenceResolver doesn't
|
||||
// support it yet. As a workaround, this tag will get handled later by api-documenter.
|
||||
// Tracked by: https://github.com/microsoft/rushstack/issues/1195
|
||||
return;
|
||||
}
|
||||
|
||||
const referencedAstDeclaration: AstDeclaration | ResolverFailure = this._collector.astReferenceResolver.resolve(
|
||||
inheritDocTag.declarationReference,
|
||||
);
|
||||
|
||||
if (referencedAstDeclaration instanceof ResolverFailure) {
|
||||
this._collector.messageRouter.addAnalyzerIssue(
|
||||
ExtractorMessageId.UnresolvedInheritDocReference,
|
||||
'The @inheritDoc reference could not be resolved: ' + referencedAstDeclaration.reason,
|
||||
astDeclaration,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this._analyzeApiItem(referencedAstDeclaration);
|
||||
|
||||
const referencedMetadata: ApiItemMetadata = this._collector.fetchApiItemMetadata(referencedAstDeclaration);
|
||||
|
||||
if (referencedMetadata.tsdocComment) {
|
||||
this._copyInheritedDocs(docComment, referencedMetadata.tsdocComment);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the content from `sourceDocComment` to `targetDocComment`.
|
||||
*/
|
||||
private _copyInheritedDocs(targetDocComment: tsdoc.DocComment, sourceDocComment: tsdoc.DocComment): void {
|
||||
targetDocComment.summarySection = sourceDocComment.summarySection;
|
||||
targetDocComment.remarksBlock = sourceDocComment.remarksBlock;
|
||||
|
||||
targetDocComment.params.clear();
|
||||
for (const param of sourceDocComment.params) {
|
||||
targetDocComment.params.add(param);
|
||||
}
|
||||
|
||||
for (const typeParam of sourceDocComment.typeParams) {
|
||||
targetDocComment.typeParams.add(typeParam);
|
||||
}
|
||||
|
||||
targetDocComment.returnsBlock = sourceDocComment.returnsBlock;
|
||||
|
||||
targetDocComment.inheritDocTag = undefined;
|
||||
}
|
||||
}
|
||||
294
packages/api-extractor/src/enhancers/ValidationEnhancer.ts
Normal file
294
packages/api-extractor/src/enhancers/ValidationEnhancer.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
// 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 { ReleaseTag, releaseTagCompare, releaseTagGetTagName } from '@discordjs/api-extractor-model';
|
||||
import * as ts from 'typescript';
|
||||
import type { AstDeclaration } from '../analyzer/AstDeclaration.js';
|
||||
import type { AstEntity } from '../analyzer/AstEntity.js';
|
||||
import type { AstModuleExportInfo } from '../analyzer/AstModule.js';
|
||||
import { AstNamespaceImport } from '../analyzer/AstNamespaceImport.js';
|
||||
import { AstSymbol } from '../analyzer/AstSymbol.js';
|
||||
import { ExtractorMessageId } from '../api/ExtractorMessageId.js';
|
||||
import type { ApiItemMetadata } from '../collector/ApiItemMetadata.js';
|
||||
import type { Collector } from '../collector/Collector.js';
|
||||
import type { CollectorEntity } from '../collector/CollectorEntity.js';
|
||||
import type { SymbolMetadata } from '../collector/SymbolMetadata.js';
|
||||
|
||||
export class ValidationEnhancer {
|
||||
public static analyze(collector: Collector): void {
|
||||
const alreadyWarnedEntities: Set<AstEntity> = new Set<AstEntity>();
|
||||
|
||||
for (const entity of collector.entities) {
|
||||
if (
|
||||
!(
|
||||
entity.consumable ||
|
||||
collector.extractorConfig.apiReportIncludeForgottenExports ||
|
||||
collector.extractorConfig.docModelIncludeForgottenExports
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entity.astEntity instanceof AstSymbol) {
|
||||
// A regular exported AstSymbol
|
||||
|
||||
const astSymbol: AstSymbol = entity.astEntity;
|
||||
|
||||
astSymbol.forEachDeclarationRecursive((astDeclaration: AstDeclaration) => {
|
||||
ValidationEnhancer._checkReferences(collector, astDeclaration, alreadyWarnedEntities);
|
||||
});
|
||||
|
||||
const symbolMetadata: SymbolMetadata = collector.fetchSymbolMetadata(astSymbol);
|
||||
ValidationEnhancer._checkForInternalUnderscore(collector, entity, astSymbol, symbolMetadata);
|
||||
ValidationEnhancer._checkForInconsistentReleaseTags(collector, astSymbol, symbolMetadata);
|
||||
} else if (entity.astEntity instanceof AstNamespaceImport) {
|
||||
// A namespace created using "import * as ___ from ___"
|
||||
const astNamespaceImport: AstNamespaceImport = entity.astEntity;
|
||||
|
||||
const astModuleExportInfo: AstModuleExportInfo = astNamespaceImport.fetchAstModuleExportInfo(collector);
|
||||
|
||||
for (const namespaceMemberAstEntity of astModuleExportInfo.exportedLocalEntities.values()) {
|
||||
if (namespaceMemberAstEntity instanceof AstSymbol) {
|
||||
const astSymbol: AstSymbol = namespaceMemberAstEntity;
|
||||
|
||||
astSymbol.forEachDeclarationRecursive((astDeclaration: AstDeclaration) => {
|
||||
ValidationEnhancer._checkReferences(collector, astDeclaration, alreadyWarnedEntities);
|
||||
});
|
||||
|
||||
const symbolMetadata: SymbolMetadata = collector.fetchSymbolMetadata(astSymbol);
|
||||
|
||||
// (Don't apply ValidationEnhancer._checkForInternalUnderscore() for AstNamespaceImport members)
|
||||
|
||||
ValidationEnhancer._checkForInconsistentReleaseTags(collector, astSymbol, symbolMetadata);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static _checkForInternalUnderscore(
|
||||
collector: Collector,
|
||||
collectorEntity: CollectorEntity,
|
||||
astSymbol: AstSymbol,
|
||||
symbolMetadata: SymbolMetadata,
|
||||
): void {
|
||||
let needsUnderscore = false;
|
||||
|
||||
if (symbolMetadata.maxEffectiveReleaseTag === ReleaseTag.Internal) {
|
||||
if (astSymbol.parentAstSymbol) {
|
||||
// If it's marked as @internal and the parent isn't obviously already @internal, then it needs an underscore.
|
||||
//
|
||||
// For example, we WOULD need an underscore for a merged declaration like this:
|
||||
//
|
||||
// /** @internal */
|
||||
// export namespace X {
|
||||
// export interface _Y { }
|
||||
// }
|
||||
//
|
||||
// /** @public */
|
||||
// export class X {
|
||||
// /** @internal */
|
||||
// public static _Y(): void { } // <==== different from parent
|
||||
// }
|
||||
const parentSymbolMetadata: SymbolMetadata = collector.fetchSymbolMetadata(astSymbol);
|
||||
if (parentSymbolMetadata.maxEffectiveReleaseTag > ReleaseTag.Internal) {
|
||||
needsUnderscore = true;
|
||||
}
|
||||
} else {
|
||||
// If it's marked as @internal and has no parent, then it needs an underscore.
|
||||
// We use maxEffectiveReleaseTag because a merged declaration would NOT need an underscore in a case like this:
|
||||
//
|
||||
// /** @public */
|
||||
// export enum X { }
|
||||
//
|
||||
// /** @internal */
|
||||
// export namespace X { }
|
||||
//
|
||||
// (The above normally reports an error "ae-different-release-tags", but that may be suppressed.)
|
||||
needsUnderscore = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (needsUnderscore) {
|
||||
for (const exportName of collectorEntity.exportNames) {
|
||||
if (!exportName.startsWith('_')) {
|
||||
collector.messageRouter.addAnalyzerIssue(
|
||||
ExtractorMessageId.InternalMissingUnderscore,
|
||||
`The name "${exportName}" should be prefixed with an underscore` +
|
||||
` because the declaration is marked as @internal`,
|
||||
astSymbol,
|
||||
{ exportName },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static _checkForInconsistentReleaseTags(
|
||||
collector: Collector,
|
||||
astSymbol: AstSymbol,
|
||||
symbolMetadata: SymbolMetadata,
|
||||
): void {
|
||||
if (astSymbol.isExternal) {
|
||||
// For now, don't report errors for external code. If the developer cares about it, they should run
|
||||
// API Extractor separately on the external project
|
||||
return;
|
||||
}
|
||||
|
||||
// Normally we will expect all release tags to be the same. Arbitrarily we choose the maxEffectiveReleaseTag
|
||||
// as the thing they should all match.
|
||||
const expectedEffectiveReleaseTag: ReleaseTag = symbolMetadata.maxEffectiveReleaseTag;
|
||||
|
||||
// This is set to true if we find a declaration whose release tag is different from expectedEffectiveReleaseTag
|
||||
let mixedReleaseTags = false;
|
||||
|
||||
// This is set to false if we find a declaration that is not a function/method overload
|
||||
let onlyFunctionOverloads = true;
|
||||
|
||||
// This is set to true if we find a declaration that is @internal
|
||||
let anyInternalReleaseTags = false;
|
||||
|
||||
for (const astDeclaration of astSymbol.astDeclarations) {
|
||||
const apiItemMetadata: ApiItemMetadata = collector.fetchApiItemMetadata(astDeclaration);
|
||||
const effectiveReleaseTag: ReleaseTag = apiItemMetadata.effectiveReleaseTag;
|
||||
|
||||
switch (astDeclaration.declaration.kind) {
|
||||
case ts.SyntaxKind.FunctionDeclaration:
|
||||
case ts.SyntaxKind.MethodDeclaration:
|
||||
break;
|
||||
default:
|
||||
onlyFunctionOverloads = false;
|
||||
}
|
||||
|
||||
if (effectiveReleaseTag !== expectedEffectiveReleaseTag) {
|
||||
mixedReleaseTags = true;
|
||||
}
|
||||
|
||||
if (effectiveReleaseTag === ReleaseTag.Internal) {
|
||||
anyInternalReleaseTags = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (mixedReleaseTags) {
|
||||
if (!onlyFunctionOverloads) {
|
||||
collector.messageRouter.addAnalyzerIssue(
|
||||
ExtractorMessageId.DifferentReleaseTags,
|
||||
'This symbol has another declaration with a different release tag',
|
||||
astSymbol,
|
||||
);
|
||||
}
|
||||
|
||||
if (anyInternalReleaseTags) {
|
||||
collector.messageRouter.addAnalyzerIssue(
|
||||
ExtractorMessageId.InternalMixedReleaseTag,
|
||||
`Mixed release tags are not allowed for "${astSymbol.localName}" because one of its declarations` +
|
||||
` is marked as @internal`,
|
||||
astSymbol,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static _checkReferences(
|
||||
collector: Collector,
|
||||
astDeclaration: AstDeclaration,
|
||||
alreadyWarnedEntities: Set<AstEntity>,
|
||||
): void {
|
||||
const apiItemMetadata: ApiItemMetadata = collector.fetchApiItemMetadata(astDeclaration);
|
||||
const declarationReleaseTag: ReleaseTag = apiItemMetadata.effectiveReleaseTag;
|
||||
|
||||
for (const referencedEntity of astDeclaration.referencedAstEntities) {
|
||||
let collectorEntity: CollectorEntity | undefined;
|
||||
let referencedReleaseTag: ReleaseTag;
|
||||
let localName: string;
|
||||
|
||||
if (referencedEntity instanceof AstSymbol) {
|
||||
// If this is e.g. a member of a namespace, then we need to be checking the top-level scope to see
|
||||
// whether it's exported.
|
||||
//
|
||||
// Technically we should also check each of the nested scopes along the way.
|
||||
const rootSymbol: AstSymbol = referencedEntity.rootAstSymbol;
|
||||
|
||||
if (rootSymbol.isExternal) {
|
||||
continue;
|
||||
}
|
||||
|
||||
collectorEntity = collector.tryGetCollectorEntity(rootSymbol);
|
||||
localName = collectorEntity?.nameForEmit ?? rootSymbol.localName;
|
||||
|
||||
const referencedMetadata: SymbolMetadata = collector.fetchSymbolMetadata(referencedEntity);
|
||||
referencedReleaseTag = referencedMetadata.maxEffectiveReleaseTag;
|
||||
} else if (referencedEntity instanceof AstNamespaceImport) {
|
||||
collectorEntity = collector.tryGetCollectorEntity(referencedEntity);
|
||||
|
||||
referencedReleaseTag = ReleaseTag.Public;
|
||||
|
||||
localName = collectorEntity?.nameForEmit ?? referencedEntity.localName;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (collectorEntity && collectorEntity.consumable) {
|
||||
if (releaseTagCompare(declarationReleaseTag, referencedReleaseTag) > 0) {
|
||||
collector.messageRouter.addAnalyzerIssue(
|
||||
ExtractorMessageId.IncompatibleReleaseTags,
|
||||
`The symbol "${astDeclaration.astSymbol.localName}"` +
|
||||
` is marked as ${releaseTagGetTagName(declarationReleaseTag)},` +
|
||||
` but its signature references "${localName}"` +
|
||||
` which is marked as ${releaseTagGetTagName(referencedReleaseTag)}`,
|
||||
astDeclaration,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const entryPointFilename: string = path.basename(collector.workingPackage.entryPointSourceFile.fileName);
|
||||
|
||||
if (!alreadyWarnedEntities.has(referencedEntity)) {
|
||||
alreadyWarnedEntities.add(referencedEntity);
|
||||
|
||||
if (referencedEntity instanceof AstSymbol && ValidationEnhancer._isEcmaScriptSymbol(referencedEntity)) {
|
||||
// The main usage scenario for ECMAScript symbols is to attach private data to a JavaScript object,
|
||||
// so as a special case, we do NOT report them as forgotten exports.
|
||||
} else {
|
||||
collector.messageRouter.addAnalyzerIssue(
|
||||
ExtractorMessageId.ForgottenExport,
|
||||
`The symbol "${localName}" needs to be exported by the entry point ${entryPointFilename}`,
|
||||
astDeclaration,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect an AstSymbol that refers to an ECMAScript symbol declaration such as:
|
||||
//
|
||||
// const mySymbol: unique symbol = Symbol('mySymbol');
|
||||
private static _isEcmaScriptSymbol(astSymbol: AstSymbol): boolean {
|
||||
if (astSymbol.astDeclarations.length !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// We are matching a form like this:
|
||||
//
|
||||
// - VariableDeclaration:
|
||||
// - Identifier: pre=[mySymbol]
|
||||
// - ColonToken: pre=[:] sep=[ ]
|
||||
// - TypeOperator:
|
||||
// - UniqueKeyword: pre=[unique] sep=[ ]
|
||||
// - SymbolKeyword: pre=[symbol]
|
||||
const astDeclaration: AstDeclaration = astSymbol.astDeclarations[0]!;
|
||||
if (ts.isVariableDeclaration(astDeclaration.declaration)) {
|
||||
const variableTypeNode: ts.TypeNode | undefined = astDeclaration.declaration.type;
|
||||
if (variableTypeNode) {
|
||||
for (const token of variableTypeNode.getChildren()) {
|
||||
if (token.kind === ts.SyntaxKind.SymbolKeyword) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
1134
packages/api-extractor/src/generators/ApiModelGenerator.ts
Normal file
1134
packages/api-extractor/src/generators/ApiModelGenerator.ts
Normal file
File diff suppressed because it is too large
Load Diff
539
packages/api-extractor/src/generators/ApiReportGenerator.ts
Normal file
539
packages/api-extractor/src/generators/ApiReportGenerator.ts
Normal file
@@ -0,0 +1,539 @@
|
||||
/* eslint-disable no-case-declarations */
|
||||
/* eslint-disable @typescript-eslint/require-array-sort-compare */
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
import { ReleaseTag, releaseTagGetTagName } from '@discordjs/api-extractor-model';
|
||||
import { Text, InternalError } from '@rushstack/node-core-library';
|
||||
import * as ts from 'typescript';
|
||||
import { AstDeclaration } from '../analyzer/AstDeclaration.js';
|
||||
import type { AstEntity } from '../analyzer/AstEntity.js';
|
||||
import { AstImport } from '../analyzer/AstImport.js';
|
||||
import type { AstModuleExportInfo } from '../analyzer/AstModule.js';
|
||||
import { AstNamespaceImport } from '../analyzer/AstNamespaceImport.js';
|
||||
import { AstSymbol } from '../analyzer/AstSymbol.js';
|
||||
import { SourceFileLocationFormatter } from '../analyzer/SourceFileLocationFormatter.js';
|
||||
import { Span } from '../analyzer/Span.js';
|
||||
import { TypeScriptHelpers } from '../analyzer/TypeScriptHelpers.js';
|
||||
import type { ExtractorMessage } from '../api/ExtractorMessage.js';
|
||||
import { ExtractorMessageId } from '../api/ExtractorMessageId.js';
|
||||
import type { ApiItemMetadata } from '../collector/ApiItemMetadata.js';
|
||||
import { Collector } from '../collector/Collector.js';
|
||||
import type { CollectorEntity } from '../collector/CollectorEntity.js';
|
||||
import { DtsEmitHelpers } from './DtsEmitHelpers.js';
|
||||
import { IndentedWriter } from './IndentedWriter.js';
|
||||
|
||||
export class ApiReportGenerator {
|
||||
private static _trimSpacesRegExp: RegExp = / +$/gm;
|
||||
|
||||
/**
|
||||
* Compares the contents of two API files that were created using ApiFileGenerator,
|
||||
* and returns true if they are equivalent. Note that these files are not normally edited
|
||||
* by a human; the "equivalence" comparison here is intended to ignore spurious changes that
|
||||
* might be introduced by a tool, e.g. Git newline normalization or an editor that strips
|
||||
* whitespace when saving.
|
||||
*/
|
||||
public static areEquivalentApiFileContents(actualFileContent: string, expectedFileContent: string): boolean {
|
||||
// NOTE: "\s" also matches "\r" and "\n"
|
||||
const normalizedActual: string = actualFileContent.replaceAll(/\s+/g, ' ');
|
||||
const normalizedExpected: string = expectedFileContent.replaceAll(/\s+/g, ' ');
|
||||
return normalizedActual === normalizedExpected;
|
||||
}
|
||||
|
||||
public static generateReviewFileContent(collector: Collector): string {
|
||||
const writer: IndentedWriter = new IndentedWriter();
|
||||
writer.trimLeadingSpaces = true;
|
||||
|
||||
writer.writeLine(
|
||||
[
|
||||
`## API Report File for "${collector.workingPackage.name}"`,
|
||||
``,
|
||||
`> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).`,
|
||||
``,
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
// Write the opening delimiter for the Markdown code fence
|
||||
writer.writeLine('```ts\n');
|
||||
|
||||
// Emit the triple slash directives
|
||||
for (const typeDirectiveReference of Array.from(collector.dtsTypeReferenceDirectives).sort()) {
|
||||
// https://github.com/microsoft/TypeScript/blob/611ebc7aadd7a44a4c0447698bfda9222a78cb66/src/compiler/declarationEmitter.ts#L162
|
||||
writer.writeLine(`/// <reference types="${typeDirectiveReference}" />`);
|
||||
}
|
||||
|
||||
for (const libDirectiveReference of Array.from(collector.dtsLibReferenceDirectives).sort()) {
|
||||
writer.writeLine(`/// <reference lib="${libDirectiveReference}" />`);
|
||||
}
|
||||
|
||||
writer.ensureSkippedLine();
|
||||
|
||||
// Emit the imports
|
||||
for (const entity of collector.entities) {
|
||||
if (entity.astEntity instanceof AstImport) {
|
||||
DtsEmitHelpers.emitImport(writer, entity, entity.astEntity);
|
||||
}
|
||||
}
|
||||
|
||||
writer.ensureSkippedLine();
|
||||
|
||||
// Emit the regular declarations
|
||||
for (const entity of collector.entities) {
|
||||
const astEntity: AstEntity = entity.astEntity;
|
||||
if (entity.consumable || collector.extractorConfig.apiReportIncludeForgottenExports) {
|
||||
// First, collect the list of export names for this symbol. When reporting messages with
|
||||
// ExtractorMessage.properties.exportName, this will enable us to emit the warning comments alongside
|
||||
// the associated export statement.
|
||||
interface IExportToEmit {
|
||||
readonly associatedMessages: ExtractorMessage[];
|
||||
readonly exportName: string;
|
||||
}
|
||||
const exportsToEmit: Map<string, IExportToEmit> = new Map<string, IExportToEmit>();
|
||||
|
||||
for (const exportName of entity.exportNames) {
|
||||
if (!entity.shouldInlineExport) {
|
||||
exportsToEmit.set(exportName, { exportName, associatedMessages: [] });
|
||||
}
|
||||
}
|
||||
|
||||
if (astEntity instanceof AstSymbol) {
|
||||
// Emit all the declarations for this entity
|
||||
for (const astDeclaration of astEntity.astDeclarations || []) {
|
||||
// Get the messages associated with this declaration
|
||||
const fetchedMessages: ExtractorMessage[] =
|
||||
collector.messageRouter.fetchAssociatedMessagesForReviewFile(astDeclaration);
|
||||
|
||||
// Peel off the messages associated with an export statement and store them
|
||||
// in IExportToEmit.associatedMessages (to be processed later). The remaining messages will
|
||||
// added to messagesToReport, to be emitted next to the declaration instead of the export statement.
|
||||
const messagesToReport: ExtractorMessage[] = [];
|
||||
for (const message of fetchedMessages) {
|
||||
if (message.properties.exportName) {
|
||||
const exportToEmit: IExportToEmit | undefined = exportsToEmit.get(message.properties.exportName);
|
||||
if (exportToEmit) {
|
||||
exportToEmit.associatedMessages.push(message);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
messagesToReport.push(message);
|
||||
}
|
||||
|
||||
writer.ensureSkippedLine();
|
||||
writer.write(ApiReportGenerator._getAedocSynopsis(collector, astDeclaration, messagesToReport));
|
||||
|
||||
const span: Span = new Span(astDeclaration.declaration);
|
||||
|
||||
const apiItemMetadata: ApiItemMetadata = collector.fetchApiItemMetadata(astDeclaration);
|
||||
if (apiItemMetadata.isPreapproved) {
|
||||
ApiReportGenerator._modifySpanForPreapproved(span);
|
||||
} else {
|
||||
ApiReportGenerator._modifySpan(collector, span, entity, astDeclaration, false);
|
||||
}
|
||||
|
||||
span.writeModifiedText(writer);
|
||||
writer.ensureNewLine();
|
||||
}
|
||||
}
|
||||
|
||||
if (astEntity instanceof AstNamespaceImport) {
|
||||
const astModuleExportInfo: AstModuleExportInfo = astEntity.fetchAstModuleExportInfo(collector);
|
||||
|
||||
if (entity.nameForEmit === undefined) {
|
||||
// This should never happen
|
||||
throw new InternalError('referencedEntry.nameForEmit is undefined');
|
||||
}
|
||||
|
||||
if (astModuleExportInfo.starExportedExternalModules.size > 0) {
|
||||
// We could support this, but we would need to find a way to safely represent it.
|
||||
throw new Error(
|
||||
`The ${entity.nameForEmit} namespace import includes a star export, which is not supported:\n` +
|
||||
SourceFileLocationFormatter.formatDeclaration(astEntity.declaration),
|
||||
);
|
||||
}
|
||||
|
||||
// Emit a synthetic declaration for the namespace. It will look like this:
|
||||
//
|
||||
// declare namespace example {
|
||||
// export {
|
||||
// f1,
|
||||
// f2
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Note that we do not try to relocate f1()/f2() to be inside the namespace because other type
|
||||
// signatures may reference them directly (without using the namespace qualifier).
|
||||
|
||||
writer.ensureSkippedLine();
|
||||
writer.writeLine(`declare namespace ${entity.nameForEmit} {`);
|
||||
|
||||
// all local exports of local imported module are just references to top-level declarations
|
||||
writer.increaseIndent();
|
||||
writer.writeLine('export {');
|
||||
writer.increaseIndent();
|
||||
|
||||
const exportClauses: string[] = [];
|
||||
for (const [exportedName, exportedEntity] of astModuleExportInfo.exportedLocalEntities) {
|
||||
const collectorEntity: CollectorEntity | undefined = collector.tryGetCollectorEntity(exportedEntity);
|
||||
if (collectorEntity === undefined) {
|
||||
// This should never happen
|
||||
// top-level exports of local imported module should be added as collector entities before
|
||||
throw new InternalError(
|
||||
`Cannot find collector entity for ${entity.nameForEmit}.${exportedEntity.localName}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (collectorEntity.nameForEmit === exportedName) {
|
||||
exportClauses.push(collectorEntity.nameForEmit);
|
||||
} else {
|
||||
exportClauses.push(`${collectorEntity.nameForEmit} as ${exportedName}`);
|
||||
}
|
||||
}
|
||||
|
||||
writer.writeLine(exportClauses.join(',\n'));
|
||||
|
||||
writer.decreaseIndent();
|
||||
writer.writeLine('}'); // end of "export { ... }"
|
||||
writer.decreaseIndent();
|
||||
writer.writeLine('}'); // end of "declare namespace { ... }"
|
||||
}
|
||||
|
||||
// Now emit the export statements for this entity.
|
||||
for (const exportToEmit of exportsToEmit.values()) {
|
||||
// Write any associated messages
|
||||
if (exportToEmit.associatedMessages.length > 0) {
|
||||
writer.ensureSkippedLine();
|
||||
for (const message of exportToEmit.associatedMessages) {
|
||||
ApiReportGenerator._writeLineAsComments(writer, 'Warning: ' + message.formatMessageWithoutLocation());
|
||||
}
|
||||
}
|
||||
|
||||
DtsEmitHelpers.emitNamedExport(writer, exportToEmit.exportName, entity);
|
||||
}
|
||||
|
||||
writer.ensureSkippedLine();
|
||||
}
|
||||
}
|
||||
|
||||
DtsEmitHelpers.emitStarExports(writer, collector);
|
||||
|
||||
// Write the unassociated warnings at the bottom of the file
|
||||
const unassociatedMessages: ExtractorMessage[] = collector.messageRouter.fetchUnassociatedMessagesForReviewFile();
|
||||
if (unassociatedMessages.length > 0) {
|
||||
writer.ensureSkippedLine();
|
||||
ApiReportGenerator._writeLineAsComments(writer, 'Warnings were encountered during analysis:');
|
||||
ApiReportGenerator._writeLineAsComments(writer, '');
|
||||
for (const unassociatedMessage of unassociatedMessages) {
|
||||
ApiReportGenerator._writeLineAsComments(
|
||||
writer,
|
||||
unassociatedMessage.formatMessageWithLocation(collector.workingPackage.packageFolder),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (collector.workingPackage.tsdocComment === undefined) {
|
||||
writer.ensureSkippedLine();
|
||||
ApiReportGenerator._writeLineAsComments(writer, '(No @packageDocumentation comment for this package)');
|
||||
}
|
||||
|
||||
// Write the closing delimiter for the Markdown code fence
|
||||
writer.ensureSkippedLine();
|
||||
writer.writeLine('```');
|
||||
|
||||
// Remove any trailing spaces
|
||||
return writer.toString().replace(ApiReportGenerator._trimSpacesRegExp, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Before writing out a declaration, _modifySpan() applies various fixups to make it nice.
|
||||
*/
|
||||
private static _modifySpan(
|
||||
collector: Collector,
|
||||
span: Span,
|
||||
entity: CollectorEntity,
|
||||
astDeclaration: AstDeclaration,
|
||||
insideTypeLiteral: boolean,
|
||||
): void {
|
||||
// Should we process this declaration at all?
|
||||
|
||||
if ((astDeclaration.modifierFlags & ts.ModifierFlags.Private) !== 0) {
|
||||
span.modification.skipAll();
|
||||
return;
|
||||
}
|
||||
|
||||
const previousSpan: Span | undefined = span.previousSibling;
|
||||
|
||||
let recurseChildren = true;
|
||||
let sortChildren = false;
|
||||
|
||||
switch (span.kind) {
|
||||
case ts.SyntaxKind.JSDocComment:
|
||||
span.modification.skipAll();
|
||||
// For now, we don't transform JSDoc comment nodes at all
|
||||
recurseChildren = false;
|
||||
break;
|
||||
|
||||
case ts.SyntaxKind.ExportKeyword:
|
||||
case ts.SyntaxKind.DefaultKeyword:
|
||||
case ts.SyntaxKind.DeclareKeyword:
|
||||
// Delete any explicit "export" or "declare" keywords -- we will re-add them below
|
||||
span.modification.skipAll();
|
||||
break;
|
||||
|
||||
case ts.SyntaxKind.InterfaceKeyword:
|
||||
case ts.SyntaxKind.ClassKeyword:
|
||||
case ts.SyntaxKind.EnumKeyword:
|
||||
case ts.SyntaxKind.NamespaceKeyword:
|
||||
case ts.SyntaxKind.ModuleKeyword:
|
||||
case ts.SyntaxKind.TypeKeyword:
|
||||
case ts.SyntaxKind.FunctionKeyword:
|
||||
// Replace the stuff we possibly deleted above
|
||||
let replacedModifiers = '';
|
||||
|
||||
if (entity.shouldInlineExport) {
|
||||
replacedModifiers = 'export ' + replacedModifiers;
|
||||
}
|
||||
|
||||
if (previousSpan && previousSpan.kind === ts.SyntaxKind.SyntaxList) {
|
||||
// If there is a previous span of type SyntaxList, then apply it before any other modifiers
|
||||
// (e.g. "abstract") that appear there.
|
||||
previousSpan.modification.prefix = replacedModifiers + previousSpan.modification.prefix;
|
||||
} else {
|
||||
// Otherwise just stick it in front of this span
|
||||
span.modification.prefix = replacedModifiers + span.modification.prefix;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case ts.SyntaxKind.SyntaxList:
|
||||
if (
|
||||
span.parent &&
|
||||
(AstDeclaration.isSupportedSyntaxKind(span.parent.kind) || span.parent.kind === ts.SyntaxKind.ModuleBlock)
|
||||
) {
|
||||
// If the immediate parent is an API declaration, and the immediate children are API declarations,
|
||||
// then sort the children alphabetically
|
||||
// Namespaces are special because their chain goes ModuleDeclaration -> ModuleBlock -> SyntaxList
|
||||
sortChildren = true;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case ts.SyntaxKind.VariableDeclaration:
|
||||
if (!span.parent) {
|
||||
// The VariableDeclaration node is part of a VariableDeclarationList, however
|
||||
// the Entry.followedSymbol points to the VariableDeclaration part because
|
||||
// multiple definitions might share the same VariableDeclarationList.
|
||||
//
|
||||
// Since we are emitting a separate declaration for each one, we need to look upwards
|
||||
// in the ts.Node tree and write a copy of the enclosing VariableDeclarationList
|
||||
// content (e.g. "var" from "var x=1, y=2").
|
||||
const list: ts.VariableDeclarationList | undefined = TypeScriptHelpers.matchAncestor(span.node, [
|
||||
ts.SyntaxKind.VariableDeclarationList,
|
||||
ts.SyntaxKind.VariableDeclaration,
|
||||
]);
|
||||
if (!list) {
|
||||
// This should not happen unless the compiler API changes somehow
|
||||
throw new InternalError('Unsupported variable declaration');
|
||||
}
|
||||
|
||||
const listPrefix: string = list.getSourceFile().text.slice(list.getStart(), list.declarations[0]!.getStart());
|
||||
span.modification.prefix = listPrefix + span.modification.prefix;
|
||||
span.modification.suffix = ';';
|
||||
|
||||
if (entity.shouldInlineExport) {
|
||||
span.modification.prefix = 'export ' + span.modification.prefix;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case ts.SyntaxKind.Identifier:
|
||||
const referencedEntity: CollectorEntity | undefined = collector.tryGetEntityForNode(span.node as ts.Identifier);
|
||||
|
||||
if (referencedEntity) {
|
||||
if (!referencedEntity.nameForEmit) {
|
||||
// This should never happen
|
||||
throw new InternalError('referencedEntry.nameForEmit is undefined');
|
||||
}
|
||||
|
||||
span.modification.prefix = referencedEntity.nameForEmit;
|
||||
// For debugging:
|
||||
// span.modification.prefix += '/*R=FIX*/';
|
||||
} else {
|
||||
// For debugging:
|
||||
// span.modification.prefix += '/*R=KEEP*/';
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case ts.SyntaxKind.TypeLiteral:
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
insideTypeLiteral = true;
|
||||
break;
|
||||
|
||||
case ts.SyntaxKind.ImportType:
|
||||
DtsEmitHelpers.modifyImportTypeSpan(collector, span, astDeclaration, (childSpan, childAstDeclaration) => {
|
||||
ApiReportGenerator._modifySpan(collector, childSpan, entity, childAstDeclaration, insideTypeLiteral);
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (recurseChildren) {
|
||||
for (const child of span.children) {
|
||||
let childAstDeclaration: AstDeclaration = astDeclaration;
|
||||
|
||||
if (AstDeclaration.isSupportedSyntaxKind(child.kind)) {
|
||||
childAstDeclaration = collector.astSymbolTable.getChildAstDeclarationByNode(child.node, astDeclaration);
|
||||
|
||||
if (sortChildren) {
|
||||
span.modification.sortChildren = true;
|
||||
child.modification.sortKey = Collector.getSortKeyIgnoringUnderscore(
|
||||
childAstDeclaration.astSymbol.localName,
|
||||
);
|
||||
}
|
||||
|
||||
if (!insideTypeLiteral) {
|
||||
const messagesToReport: ExtractorMessage[] =
|
||||
collector.messageRouter.fetchAssociatedMessagesForReviewFile(childAstDeclaration);
|
||||
const aedocSynopsis: string = ApiReportGenerator._getAedocSynopsis(
|
||||
collector,
|
||||
childAstDeclaration,
|
||||
messagesToReport,
|
||||
);
|
||||
|
||||
child.modification.prefix = aedocSynopsis + child.modification.prefix;
|
||||
}
|
||||
}
|
||||
|
||||
ApiReportGenerator._modifySpan(collector, child, entity, childAstDeclaration, insideTypeLiteral);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For declarations marked as `@preapproved`, this is used instead of _modifySpan().
|
||||
*/
|
||||
private static _modifySpanForPreapproved(span: Span): void {
|
||||
// Match something like this:
|
||||
//
|
||||
// ClassDeclaration:
|
||||
// SyntaxList:
|
||||
// ExportKeyword: pre=[export] sep=[ ]
|
||||
// DeclareKeyword: pre=[declare] sep=[ ]
|
||||
// ClassKeyword: pre=[class] sep=[ ]
|
||||
// Identifier: pre=[_PreapprovedClass] sep=[ ]
|
||||
// FirstPunctuation: pre=[{] sep=[\n\n ]
|
||||
// SyntaxList:
|
||||
// ...
|
||||
// CloseBraceToken: pre=[}]
|
||||
//
|
||||
// or this:
|
||||
// ModuleDeclaration:
|
||||
// SyntaxList:
|
||||
// ExportKeyword: pre=[export] sep=[ ]
|
||||
// DeclareKeyword: pre=[declare] sep=[ ]
|
||||
// NamespaceKeyword: pre=[namespace] sep=[ ]
|
||||
// Identifier: pre=[_PreapprovedNamespace] sep=[ ]
|
||||
// ModuleBlock:
|
||||
// FirstPunctuation: pre=[{] sep=[\n\n ]
|
||||
// SyntaxList:
|
||||
// ...
|
||||
// CloseBraceToken: pre=[}]
|
||||
//
|
||||
// And reduce it to something like this:
|
||||
//
|
||||
// // @internal (undocumented)
|
||||
// class _PreapprovedClass { /* (preapproved) */ }
|
||||
//
|
||||
|
||||
let skipRest = false;
|
||||
for (const child of span.children) {
|
||||
if (skipRest || child.kind === ts.SyntaxKind.SyntaxList || child.kind === ts.SyntaxKind.JSDocComment) {
|
||||
child.modification.skipAll();
|
||||
}
|
||||
|
||||
if (child.kind === ts.SyntaxKind.Identifier) {
|
||||
skipRest = true;
|
||||
child.modification.omitSeparatorAfter = true;
|
||||
child.modification.suffix = ' { /* (preapproved) */ }';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a synopsis of the AEDoc comments, which indicates the release tag,
|
||||
* whether the item has been documented, and any warnings that were detected
|
||||
* by the analysis.
|
||||
*/
|
||||
private static _getAedocSynopsis(
|
||||
collector: Collector,
|
||||
astDeclaration: AstDeclaration,
|
||||
messagesToReport: ExtractorMessage[],
|
||||
): string {
|
||||
const writer: IndentedWriter = new IndentedWriter();
|
||||
|
||||
for (const message of messagesToReport) {
|
||||
ApiReportGenerator._writeLineAsComments(writer, 'Warning: ' + message.formatMessageWithoutLocation());
|
||||
}
|
||||
|
||||
if (!collector.isAncillaryDeclaration(astDeclaration)) {
|
||||
const footerParts: string[] = [];
|
||||
const apiItemMetadata: ApiItemMetadata = collector.fetchApiItemMetadata(astDeclaration);
|
||||
if (!apiItemMetadata.releaseTagSameAsParent && apiItemMetadata.effectiveReleaseTag !== ReleaseTag.None) {
|
||||
footerParts.push(releaseTagGetTagName(apiItemMetadata.effectiveReleaseTag));
|
||||
}
|
||||
|
||||
if (apiItemMetadata.isSealed) {
|
||||
footerParts.push('@sealed');
|
||||
}
|
||||
|
||||
if (apiItemMetadata.isVirtual) {
|
||||
footerParts.push('@virtual');
|
||||
}
|
||||
|
||||
if (apiItemMetadata.isOverride) {
|
||||
footerParts.push('@override');
|
||||
}
|
||||
|
||||
if (apiItemMetadata.isEventProperty) {
|
||||
footerParts.push('@eventProperty');
|
||||
}
|
||||
|
||||
if (apiItemMetadata.tsdocComment?.deprecatedBlock) {
|
||||
footerParts.push('@deprecated');
|
||||
}
|
||||
|
||||
if (apiItemMetadata.undocumented) {
|
||||
footerParts.push('(undocumented)');
|
||||
|
||||
collector.messageRouter.addAnalyzerIssue(
|
||||
ExtractorMessageId.Undocumented,
|
||||
`Missing documentation for "${astDeclaration.astSymbol.localName}".`,
|
||||
astDeclaration,
|
||||
);
|
||||
}
|
||||
|
||||
if (footerParts.length > 0) {
|
||||
if (messagesToReport.length > 0) {
|
||||
ApiReportGenerator._writeLineAsComments(writer, ''); // skip a line after the warnings
|
||||
}
|
||||
|
||||
ApiReportGenerator._writeLineAsComments(writer, footerParts.join(' '));
|
||||
}
|
||||
}
|
||||
|
||||
return writer.toString();
|
||||
}
|
||||
|
||||
private static _writeLineAsComments(writer: IndentedWriter, line: string): void {
|
||||
const lines: string[] = Text.convertToLf(line).split('\n');
|
||||
for (const realLine of lines) {
|
||||
writer.write('// ');
|
||||
writer.write(realLine);
|
||||
writer.writeLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
import { Navigation, Meaning } from '@discordjs/api-extractor-model';
|
||||
import {
|
||||
DeclarationReference,
|
||||
ModuleSource,
|
||||
GlobalSource,
|
||||
} from '@microsoft/tsdoc/lib-commonjs/beta/DeclarationReference.js';
|
||||
import { type INodePackageJson, InternalError } from '@rushstack/node-core-library';
|
||||
import * as ts from 'typescript';
|
||||
import { AstNamespaceImport } from '../analyzer/AstNamespaceImport.js';
|
||||
import { TypeScriptHelpers } from '../analyzer/TypeScriptHelpers.js';
|
||||
import { TypeScriptInternals } from '../analyzer/TypeScriptInternals.js';
|
||||
import type { Collector } from '../collector/Collector.js';
|
||||
import type { CollectorEntity } from '../collector/CollectorEntity.js';
|
||||
|
||||
export class DeclarationReferenceGenerator {
|
||||
public static readonly unknownReference: string = '?';
|
||||
|
||||
private readonly _collector: Collector;
|
||||
|
||||
public constructor(collector: Collector) {
|
||||
this._collector = collector;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the UID for a TypeScript Identifier that references a type.
|
||||
*/
|
||||
public getDeclarationReferenceForIdentifier(node: ts.Identifier): DeclarationReference | undefined {
|
||||
const symbol: ts.Symbol | undefined = this._collector.typeChecker.getSymbolAtLocation(node);
|
||||
if (symbol !== undefined) {
|
||||
const isExpression: boolean = DeclarationReferenceGenerator._isInExpressionContext(node);
|
||||
return (
|
||||
this.getDeclarationReferenceForSymbol(symbol, isExpression ? ts.SymbolFlags.Value : ts.SymbolFlags.Type) ??
|
||||
this.getDeclarationReferenceForSymbol(symbol, isExpression ? ts.SymbolFlags.Type : ts.SymbolFlags.Value) ??
|
||||
this.getDeclarationReferenceForSymbol(symbol, ts.SymbolFlags.Namespace)
|
||||
);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the DeclarationReference for a TypeScript Symbol for a given meaning.
|
||||
*/
|
||||
public getDeclarationReferenceForSymbol(
|
||||
symbol: ts.Symbol,
|
||||
meaning: ts.SymbolFlags,
|
||||
): DeclarationReference | undefined {
|
||||
return this._symbolToDeclarationReference(symbol, meaning, /* includeModuleSymbols*/ false);
|
||||
}
|
||||
|
||||
private static _isInExpressionContext(node: ts.Node): boolean {
|
||||
switch (node.parent.kind) {
|
||||
case ts.SyntaxKind.TypeQuery:
|
||||
case ts.SyntaxKind.ComputedPropertyName:
|
||||
return true;
|
||||
case ts.SyntaxKind.QualifiedName:
|
||||
return DeclarationReferenceGenerator._isInExpressionContext(node.parent);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static _isExternalModuleSymbol(symbol: ts.Symbol): boolean {
|
||||
return (
|
||||
Boolean(symbol.flags & ts.SymbolFlags.ValueModule) &&
|
||||
symbol.valueDeclaration !== undefined &&
|
||||
ts.isSourceFile(symbol.valueDeclaration)
|
||||
);
|
||||
}
|
||||
|
||||
private static _isSameSymbol(left: ts.Symbol | undefined, right: ts.Symbol): boolean {
|
||||
return (
|
||||
left === right ||
|
||||
Boolean(left?.valueDeclaration && right.valueDeclaration && left.valueDeclaration === right.valueDeclaration)
|
||||
);
|
||||
}
|
||||
|
||||
private _getNavigationToSymbol(symbol: ts.Symbol): Navigation {
|
||||
const declaration: ts.Declaration | undefined = TypeScriptHelpers.tryGetADeclaration(symbol);
|
||||
const sourceFile: ts.SourceFile | undefined = declaration?.getSourceFile();
|
||||
const parent: ts.Symbol | undefined = TypeScriptInternals.getSymbolParent(symbol);
|
||||
|
||||
// If it's global or from an external library, then use either Members or Exports. It's not possible for
|
||||
// global symbols or external library symbols to be Locals.
|
||||
const isGlobal: boolean = Boolean(sourceFile) && !ts.isExternalModule(sourceFile!);
|
||||
const isFromExternalLibrary: boolean =
|
||||
Boolean(sourceFile) && this._collector.program.isSourceFileFromExternalLibrary(sourceFile!);
|
||||
if (isGlobal || isFromExternalLibrary) {
|
||||
if (
|
||||
parent?.members &&
|
||||
DeclarationReferenceGenerator._isSameSymbol(parent.members.get(symbol.escapedName), symbol)
|
||||
) {
|
||||
return Navigation.Members;
|
||||
}
|
||||
|
||||
return Navigation.Exports;
|
||||
}
|
||||
|
||||
// Otherwise, this symbol is from the current package. If we've found an associated consumable
|
||||
// `CollectorEntity`, then use Exports. We use `consumable` here instead of `exported` because
|
||||
// if the symbol is exported from a non-consumable `AstNamespaceImport`, we don't want to use
|
||||
// Exports. We should use Locals instead.
|
||||
const entity: CollectorEntity | undefined = this._collector.tryGetEntityForSymbol(symbol);
|
||||
if (entity?.consumable) {
|
||||
return Navigation.Exports;
|
||||
}
|
||||
|
||||
// If its parent symbol is not a source file, then use either Exports or Members. If the parent symbol
|
||||
// is a source file, but it wasn't exported from the package entry point (in the check above), then the
|
||||
// symbol is a local, so fall through below.
|
||||
if (parent && !DeclarationReferenceGenerator._isExternalModuleSymbol(parent)) {
|
||||
if (
|
||||
parent.members &&
|
||||
DeclarationReferenceGenerator._isSameSymbol(parent.members.get(symbol.escapedName), symbol)
|
||||
) {
|
||||
return Navigation.Members;
|
||||
}
|
||||
|
||||
return Navigation.Exports;
|
||||
}
|
||||
|
||||
// Otherwise, we have a local symbol, so use a Locals navigation. These are either:
|
||||
//
|
||||
// 1. Symbols that are exported from a file module but not the package entry point.
|
||||
// 2. Symbols that are not exported from their parent module.
|
||||
return Navigation.Locals;
|
||||
}
|
||||
|
||||
private static _getMeaningOfSymbol(symbol: ts.Symbol, meaning: ts.SymbolFlags): Meaning | undefined {
|
||||
if (symbol.flags & meaning & ts.SymbolFlags.Class) {
|
||||
return Meaning.Class;
|
||||
}
|
||||
|
||||
if (symbol.flags & meaning & ts.SymbolFlags.Enum) {
|
||||
return Meaning.Enum;
|
||||
}
|
||||
|
||||
if (symbol.flags & meaning & ts.SymbolFlags.Interface) {
|
||||
return Meaning.Interface;
|
||||
}
|
||||
|
||||
if (symbol.flags & meaning & ts.SymbolFlags.TypeAlias) {
|
||||
return Meaning.TypeAlias;
|
||||
}
|
||||
|
||||
if (symbol.flags & meaning & ts.SymbolFlags.Function) {
|
||||
return Meaning.Function;
|
||||
}
|
||||
|
||||
if (symbol.flags & meaning & ts.SymbolFlags.Variable) {
|
||||
return Meaning.Variable;
|
||||
}
|
||||
|
||||
if (symbol.flags & meaning & ts.SymbolFlags.Module) {
|
||||
return Meaning.Namespace;
|
||||
}
|
||||
|
||||
if (symbol.flags & meaning & ts.SymbolFlags.ClassMember) {
|
||||
return Meaning.Member;
|
||||
}
|
||||
|
||||
if (symbol.flags & meaning & ts.SymbolFlags.Constructor) {
|
||||
return Meaning.Constructor;
|
||||
}
|
||||
|
||||
if (symbol.flags & meaning & ts.SymbolFlags.EnumMember) {
|
||||
return Meaning.Member;
|
||||
}
|
||||
|
||||
if (symbol.flags & meaning & ts.SymbolFlags.Signature) {
|
||||
if (symbol.escapedName === ts.InternalSymbolName.Call) {
|
||||
return Meaning.CallSignature;
|
||||
}
|
||||
|
||||
if (symbol.escapedName === ts.InternalSymbolName.New) {
|
||||
return Meaning.ConstructSignature;
|
||||
}
|
||||
|
||||
if (symbol.escapedName === ts.InternalSymbolName.Index) {
|
||||
return Meaning.IndexSignature;
|
||||
}
|
||||
}
|
||||
|
||||
if (symbol.flags & meaning & ts.SymbolFlags.TypeParameter) {
|
||||
// This should have already been handled in `getDeclarationReferenceOfSymbol`.
|
||||
throw new InternalError('Not supported.');
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private _symbolToDeclarationReference(
|
||||
symbol: ts.Symbol,
|
||||
meaning: ts.SymbolFlags,
|
||||
includeModuleSymbols: boolean,
|
||||
): DeclarationReference | undefined {
|
||||
const declaration: ts.Node | undefined = TypeScriptHelpers.tryGetADeclaration(symbol);
|
||||
const sourceFile: ts.SourceFile | undefined = declaration?.getSourceFile();
|
||||
|
||||
let followedSymbol: ts.Symbol = symbol;
|
||||
if (followedSymbol.flags & ts.SymbolFlags.ExportValue) {
|
||||
followedSymbol = this._collector.typeChecker.getExportSymbolOfSymbol(followedSymbol);
|
||||
}
|
||||
|
||||
if (followedSymbol.flags & ts.SymbolFlags.Alias) {
|
||||
followedSymbol = this._collector.typeChecker.getAliasedSymbol(followedSymbol);
|
||||
|
||||
// Without this logic, we end up following the symbol `ns` in `import * as ns from './file'` to
|
||||
// the actual file `file.ts`. We don't want to do this, so revert to the original symbol.
|
||||
if (followedSymbol.flags & ts.SymbolFlags.ValueModule) {
|
||||
followedSymbol = symbol;
|
||||
}
|
||||
}
|
||||
|
||||
if (DeclarationReferenceGenerator._isExternalModuleSymbol(followedSymbol)) {
|
||||
if (!includeModuleSymbols) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return new DeclarationReference(this._sourceFileToModuleSource(sourceFile));
|
||||
}
|
||||
|
||||
// Do not generate a declaration reference for a type parameter.
|
||||
if (followedSymbol.flags & ts.SymbolFlags.TypeParameter) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let parentRef: DeclarationReference | undefined = this._getParentReference(followedSymbol);
|
||||
if (!parentRef) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let localName: string = followedSymbol.name;
|
||||
const entity: CollectorEntity | undefined = this._collector.tryGetEntityForSymbol(followedSymbol);
|
||||
if (entity?.nameForEmit) {
|
||||
localName = entity.nameForEmit;
|
||||
}
|
||||
|
||||
if (followedSymbol.escapedName === ts.InternalSymbolName.Constructor) {
|
||||
localName = 'constructor';
|
||||
} else {
|
||||
const wellKnownName: string | undefined = TypeScriptHelpers.tryDecodeWellKnownSymbolName(
|
||||
followedSymbol.escapedName,
|
||||
);
|
||||
if (wellKnownName) {
|
||||
// TypeScript binds well-known ECMAScript symbols like 'Symbol.iterator' as '__@iterator'.
|
||||
// This converts a string like '__@iterator' into the property name '[Symbol.iterator]'.
|
||||
localName = wellKnownName;
|
||||
} else if (TypeScriptHelpers.isUniqueSymbolName(followedSymbol.escapedName)) {
|
||||
for (const decl of followedSymbol.declarations ?? []) {
|
||||
const declName: ts.DeclarationName | undefined = ts.getNameOfDeclaration(decl);
|
||||
if (declName && ts.isComputedPropertyName(declName)) {
|
||||
const lateName: string | undefined = TypeScriptHelpers.tryGetLateBoundName(declName);
|
||||
if (lateName !== undefined) {
|
||||
localName = lateName;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const navigation: Navigation = this._getNavigationToSymbol(followedSymbol);
|
||||
|
||||
// If the symbol is a global, ensure the source is global.
|
||||
if (sourceFile && !ts.isExternalModule(sourceFile) && parentRef.source !== GlobalSource.instance) {
|
||||
parentRef = new DeclarationReference(GlobalSource.instance);
|
||||
}
|
||||
|
||||
return parentRef
|
||||
.addNavigationStep(navigation as any, localName)
|
||||
.withMeaning(DeclarationReferenceGenerator._getMeaningOfSymbol(followedSymbol, meaning) as any);
|
||||
}
|
||||
|
||||
private _getParentReference(symbol: ts.Symbol): DeclarationReference | undefined {
|
||||
const declaration: ts.Node | undefined = TypeScriptHelpers.tryGetADeclaration(symbol);
|
||||
const sourceFile: ts.SourceFile | undefined = declaration?.getSourceFile();
|
||||
|
||||
// Note that it's possible for a symbol to be exported from an entry point as well as one or more
|
||||
// namespaces. In that case, it's not clear what to choose as its parent. Today's logic is neither
|
||||
// perfect nor particularly stable to API items being renamed and shuffled around.
|
||||
const entity: CollectorEntity | undefined = this._collector.tryGetEntityForSymbol(symbol);
|
||||
if (entity) {
|
||||
if (entity.exportedFromEntryPoint) {
|
||||
return new DeclarationReference(this._sourceFileToModuleSource(sourceFile));
|
||||
}
|
||||
|
||||
const firstExportingConsumableParent: CollectorEntity | undefined = entity.getFirstExportingConsumableParent();
|
||||
if (firstExportingConsumableParent && firstExportingConsumableParent.astEntity instanceof AstNamespaceImport) {
|
||||
const parentSymbol: ts.Symbol | undefined = TypeScriptInternals.tryGetSymbolForDeclaration(
|
||||
firstExportingConsumableParent.astEntity.declaration,
|
||||
this._collector.typeChecker,
|
||||
);
|
||||
if (parentSymbol) {
|
||||
return this._symbolToDeclarationReference(parentSymbol, parentSymbol.flags, /* includeModuleSymbols*/ true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Next, try to find a parent symbol via the symbol tree.
|
||||
const parentSymbol: ts.Symbol | undefined = TypeScriptInternals.getSymbolParent(symbol);
|
||||
if (parentSymbol) {
|
||||
return this._symbolToDeclarationReference(parentSymbol, parentSymbol.flags, /* includeModuleSymbols*/ true);
|
||||
}
|
||||
|
||||
// If that doesn't work, try to find a parent symbol via the node tree. As far as we can tell,
|
||||
// this logic is only needed for local symbols within namespaces. For example:
|
||||
//
|
||||
// ```
|
||||
// export namespace n {
|
||||
// type SomeType = number;
|
||||
// export function someFunction(): SomeType { return 5; }
|
||||
// }
|
||||
// ```
|
||||
//
|
||||
// In the example above, `SomeType` doesn't have a parent symbol per the TS internal API above,
|
||||
// but its reference still needs to be qualified with the parent reference for `n`.
|
||||
const grandParent: ts.Node | undefined = declaration?.parent?.parent;
|
||||
if (grandParent && ts.isModuleDeclaration(grandParent)) {
|
||||
const grandParentSymbol: ts.Symbol | undefined = TypeScriptInternals.tryGetSymbolForDeclaration(
|
||||
grandParent,
|
||||
this._collector.typeChecker,
|
||||
);
|
||||
if (grandParentSymbol) {
|
||||
return this._symbolToDeclarationReference(
|
||||
grandParentSymbol,
|
||||
grandParentSymbol.flags,
|
||||
/* includeModuleSymbols*/ true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// At this point, we have a local symbol in a module.
|
||||
if (sourceFile && ts.isExternalModule(sourceFile)) {
|
||||
return new DeclarationReference(this._sourceFileToModuleSource(sourceFile));
|
||||
} else {
|
||||
return new DeclarationReference(GlobalSource.instance);
|
||||
}
|
||||
}
|
||||
|
||||
private _getPackageName(sourceFile: ts.SourceFile): string {
|
||||
if (this._collector.program.isSourceFileFromExternalLibrary(sourceFile)) {
|
||||
const packageJson: INodePackageJson | undefined = this._collector.packageJsonLookup.tryLoadNodePackageJsonFor(
|
||||
sourceFile.fileName,
|
||||
);
|
||||
|
||||
if (packageJson?.name) {
|
||||
return packageJson.name;
|
||||
}
|
||||
|
||||
return DeclarationReferenceGenerator.unknownReference;
|
||||
}
|
||||
|
||||
return this._collector.workingPackage.name;
|
||||
}
|
||||
|
||||
private _sourceFileToModuleSource(sourceFile: ts.SourceFile | undefined): GlobalSource | ModuleSource {
|
||||
if (sourceFile && ts.isExternalModule(sourceFile)) {
|
||||
const packageName: string = this._getPackageName(sourceFile);
|
||||
|
||||
if (this._collector.bundledPackageNames.has(packageName)) {
|
||||
// The api-extractor.json config file has a "bundledPackages" setting, which causes imports from
|
||||
// certain NPM packages to be treated as part of the working project. In this case, we need to
|
||||
// substitute the working package name.
|
||||
return new ModuleSource(this._collector.workingPackage.name);
|
||||
} else {
|
||||
return new ModuleSource(packageName);
|
||||
}
|
||||
}
|
||||
|
||||
return GlobalSource.instance;
|
||||
}
|
||||
}
|
||||
159
packages/api-extractor/src/generators/DtsEmitHelpers.ts
Normal file
159
packages/api-extractor/src/generators/DtsEmitHelpers.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
// 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 { AstDeclaration } from '../analyzer/AstDeclaration.js';
|
||||
import { AstImport, AstImportKind } from '../analyzer/AstImport.js';
|
||||
import { SourceFileLocationFormatter } from '../analyzer/SourceFileLocationFormatter.js';
|
||||
import type { Span } from '../analyzer/Span.js';
|
||||
import type { Collector } from '../collector/Collector.js';
|
||||
import type { CollectorEntity } from '../collector/CollectorEntity.js';
|
||||
import type { IndentedWriter } from './IndentedWriter.js';
|
||||
|
||||
/**
|
||||
* Some common code shared between DtsRollupGenerator and ApiReportGenerator.
|
||||
*/
|
||||
export class DtsEmitHelpers {
|
||||
public static emitImport(writer: IndentedWriter, collectorEntity: CollectorEntity, astImport: AstImport): void {
|
||||
const importPrefix: string = astImport.isTypeOnlyEverywhere ? 'import type' : 'import';
|
||||
|
||||
switch (astImport.importKind) {
|
||||
case AstImportKind.DefaultImport:
|
||||
if (collectorEntity.nameForEmit === astImport.exportName) {
|
||||
writer.write(`${importPrefix} ${astImport.exportName}`);
|
||||
} else {
|
||||
writer.write(`${importPrefix} { default as ${collectorEntity.nameForEmit} }`);
|
||||
}
|
||||
|
||||
writer.writeLine(` from '${astImport.modulePath}';`);
|
||||
break;
|
||||
case AstImportKind.NamedImport:
|
||||
if (collectorEntity.nameForEmit === astImport.exportName) {
|
||||
writer.write(`${importPrefix} { ${astImport.exportName} }`);
|
||||
} else {
|
||||
writer.write(`${importPrefix} { ${astImport.exportName} as ${collectorEntity.nameForEmit} }`);
|
||||
}
|
||||
|
||||
writer.writeLine(` from '${astImport.modulePath}';`);
|
||||
break;
|
||||
case AstImportKind.StarImport:
|
||||
writer.writeLine(`${importPrefix} * as ${collectorEntity.nameForEmit} from '${astImport.modulePath}';`);
|
||||
break;
|
||||
case AstImportKind.EqualsImport:
|
||||
writer.writeLine(`${importPrefix} ${collectorEntity.nameForEmit} = require('${astImport.modulePath}');`);
|
||||
break;
|
||||
case AstImportKind.ImportType:
|
||||
if (astImport.exportName) {
|
||||
const topExportName: string = astImport.exportName.split('.')[0]!;
|
||||
if (collectorEntity.nameForEmit === topExportName) {
|
||||
writer.write(`${importPrefix} { ${topExportName} }`);
|
||||
} else {
|
||||
writer.write(`${importPrefix} { ${topExportName} as ${collectorEntity.nameForEmit} }`);
|
||||
}
|
||||
|
||||
writer.writeLine(` from '${astImport.modulePath}';`);
|
||||
} else {
|
||||
writer.writeLine(`${importPrefix} * as ${collectorEntity.nameForEmit} from '${astImport.modulePath}';`);
|
||||
}
|
||||
|
||||
break;
|
||||
default:
|
||||
throw new InternalError('Unimplemented AstImportKind');
|
||||
}
|
||||
}
|
||||
|
||||
public static emitNamedExport(writer: IndentedWriter, exportName: string, collectorEntity: CollectorEntity): void {
|
||||
if (exportName === ts.InternalSymbolName.Default) {
|
||||
writer.writeLine(`export default ${collectorEntity.nameForEmit};`);
|
||||
} else if (collectorEntity.nameForEmit === exportName) {
|
||||
writer.writeLine(`export { ${exportName} }`);
|
||||
} else {
|
||||
writer.writeLine(`export { ${collectorEntity.nameForEmit} as ${exportName} }`);
|
||||
}
|
||||
}
|
||||
|
||||
public static emitStarExports(writer: IndentedWriter, collector: Collector): void {
|
||||
if (collector.starExportedExternalModulePaths.length > 0) {
|
||||
writer.writeLine();
|
||||
for (const starExportedExternalModulePath of collector.starExportedExternalModulePaths) {
|
||||
writer.writeLine(`export * from "${starExportedExternalModulePath}";`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static modifyImportTypeSpan(
|
||||
collector: Collector,
|
||||
span: Span,
|
||||
astDeclaration: AstDeclaration,
|
||||
modifyNestedSpan: (childSpan: Span, childAstDeclaration: AstDeclaration) => void,
|
||||
): void {
|
||||
const node: ts.ImportTypeNode = span.node as ts.ImportTypeNode;
|
||||
const referencedEntity: CollectorEntity | undefined = collector.tryGetEntityForNode(node);
|
||||
|
||||
if (referencedEntity) {
|
||||
if (!referencedEntity.nameForEmit) {
|
||||
// This should never happen
|
||||
|
||||
throw new InternalError('referencedEntry.nameForEmit is undefined');
|
||||
}
|
||||
|
||||
let typeArgumentsText = '';
|
||||
|
||||
if (node.typeArguments && node.typeArguments.length > 0) {
|
||||
// Type arguments have to be processed and written to the document
|
||||
const lessThanTokenPos: number = span.children.findIndex(
|
||||
(childSpan) => childSpan.node.kind === ts.SyntaxKind.LessThanToken,
|
||||
);
|
||||
const greaterThanTokenPos: number = span.children.findIndex(
|
||||
(childSpan) => childSpan.node.kind === ts.SyntaxKind.GreaterThanToken,
|
||||
);
|
||||
|
||||
if (lessThanTokenPos < 0 || greaterThanTokenPos <= lessThanTokenPos) {
|
||||
throw new InternalError(
|
||||
`Invalid type arguments: ${node.getText()}\n` + SourceFileLocationFormatter.formatDeclaration(node),
|
||||
);
|
||||
}
|
||||
|
||||
const typeArgumentsSpans: Span[] = span.children.slice(lessThanTokenPos + 1, greaterThanTokenPos);
|
||||
|
||||
// Apply modifications to Span elements of typeArguments
|
||||
for (const childSpan of typeArgumentsSpans) {
|
||||
const childAstDeclaration: AstDeclaration = AstDeclaration.isSupportedSyntaxKind(childSpan.kind)
|
||||
? collector.astSymbolTable.getChildAstDeclarationByNode(childSpan.node, astDeclaration)
|
||||
: astDeclaration;
|
||||
|
||||
modifyNestedSpan(childSpan, childAstDeclaration);
|
||||
}
|
||||
|
||||
const typeArgumentsStrings: string[] = typeArgumentsSpans.map((childSpan) => childSpan.getModifiedText());
|
||||
typeArgumentsText = `<${typeArgumentsStrings.join(', ')}>`;
|
||||
}
|
||||
|
||||
const separatorAfter: string = /(?<separator>\s*)$/.exec(span.getText())?.groups?.separator ?? '';
|
||||
|
||||
if (
|
||||
referencedEntity.astEntity instanceof AstImport &&
|
||||
referencedEntity.astEntity.importKind === AstImportKind.ImportType &&
|
||||
referencedEntity.astEntity.exportName
|
||||
) {
|
||||
// For an ImportType with a namespace chain, only the top namespace is imported.
|
||||
// Must add the original nested qualifiers to the rolled up import.
|
||||
const qualifiersText: string = node.qualifier?.getText() ?? '';
|
||||
const nestedQualifiersStart: number = qualifiersText.indexOf('.');
|
||||
// Including the leading "."
|
||||
const nestedQualifiersText: string =
|
||||
nestedQualifiersStart >= 0 ? qualifiersText.slice(Math.max(0, nestedQualifiersStart)) : '';
|
||||
|
||||
const replacement = `${referencedEntity.nameForEmit}${nestedQualifiersText}${typeArgumentsText}${separatorAfter}`;
|
||||
|
||||
span.modification.skipAll();
|
||||
span.modification.prefix = replacement;
|
||||
} else {
|
||||
// Replace with internal symbol or AstImport
|
||||
span.modification.skipAll();
|
||||
span.modification.prefix = `${referencedEntity.nameForEmit}${typeArgumentsText}${separatorAfter}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
473
packages/api-extractor/src/generators/DtsRollupGenerator.ts
Normal file
473
packages/api-extractor/src/generators/DtsRollupGenerator.ts
Normal file
@@ -0,0 +1,473 @@
|
||||
/* eslint-disable no-case-declarations */
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
import { ReleaseTag } from '@discordjs/api-extractor-model';
|
||||
import { FileSystem, type NewlineKind, InternalError } from '@rushstack/node-core-library';
|
||||
import * as ts from 'typescript';
|
||||
import { AstDeclaration } from '../analyzer/AstDeclaration.js';
|
||||
import type { AstEntity } from '../analyzer/AstEntity.js';
|
||||
import { AstImport } from '../analyzer/AstImport.js';
|
||||
import type { AstModuleExportInfo } from '../analyzer/AstModule.js';
|
||||
import { AstNamespaceImport } from '../analyzer/AstNamespaceImport.js';
|
||||
import { AstSymbol } from '../analyzer/AstSymbol.js';
|
||||
import { SourceFileLocationFormatter } from '../analyzer/SourceFileLocationFormatter.js';
|
||||
import { IndentDocCommentScope, Span, type SpanModification } from '../analyzer/Span.js';
|
||||
import { TypeScriptHelpers } from '../analyzer/TypeScriptHelpers.js';
|
||||
import type { ApiItemMetadata } from '../collector/ApiItemMetadata.js';
|
||||
import type { Collector } from '../collector/Collector.js';
|
||||
import type { CollectorEntity } from '../collector/CollectorEntity.js';
|
||||
import type { DeclarationMetadata } from '../collector/DeclarationMetadata.js';
|
||||
import type { SymbolMetadata } from '../collector/SymbolMetadata.js';
|
||||
import { DtsEmitHelpers } from './DtsEmitHelpers.js';
|
||||
import { IndentedWriter } from './IndentedWriter.js';
|
||||
|
||||
/**
|
||||
* Used with DtsRollupGenerator.writeTypingsFile()
|
||||
*/
|
||||
export enum DtsRollupKind {
|
||||
/**
|
||||
* Generate a *.d.ts file for an internal release, or for the trimming=false mode.
|
||||
* This output file will contain all definitions that are reachable from the entry point.
|
||||
*/
|
||||
InternalRelease,
|
||||
|
||||
/**
|
||||
* Generate a *.d.ts file for a preview release.
|
||||
* This output file will contain all definitions that are reachable from the entry point,
|
||||
* except definitions marked as \@internal.
|
||||
*/
|
||||
AlphaRelease,
|
||||
|
||||
/**
|
||||
* Generate a *.d.ts file for a preview release.
|
||||
* This output file will contain all definitions that are reachable from the entry point,
|
||||
* except definitions marked as \@alpha or \@internal.
|
||||
*/
|
||||
BetaRelease,
|
||||
|
||||
/**
|
||||
* Generate a *.d.ts file for a public release.
|
||||
* This output file will contain all definitions that are reachable from the entry point,
|
||||
* except definitions marked as \@beta, \@alpha, or \@internal.
|
||||
*/
|
||||
PublicRelease,
|
||||
}
|
||||
|
||||
export class DtsRollupGenerator {
|
||||
/**
|
||||
* Generates the typings file and writes it to disk.
|
||||
*
|
||||
* @param collector - The Collector
|
||||
* @param dtsFilename - The *.d.ts output filename
|
||||
*/
|
||||
public static writeTypingsFile(
|
||||
collector: Collector,
|
||||
dtsFilename: string,
|
||||
dtsKind: DtsRollupKind,
|
||||
newlineKind: NewlineKind,
|
||||
): void {
|
||||
const writer: IndentedWriter = new IndentedWriter();
|
||||
writer.trimLeadingSpaces = true;
|
||||
|
||||
DtsRollupGenerator._generateTypingsFileContent(collector, writer, dtsKind);
|
||||
|
||||
FileSystem.writeFile(dtsFilename, writer.toString(), {
|
||||
convertLineEndings: newlineKind,
|
||||
ensureFolderExists: true,
|
||||
});
|
||||
}
|
||||
|
||||
private static _generateTypingsFileContent(
|
||||
collector: Collector,
|
||||
writer: IndentedWriter,
|
||||
dtsKind: DtsRollupKind,
|
||||
): void {
|
||||
// Emit the @packageDocumentation comment at the top of the file
|
||||
if (collector.workingPackage.tsdocParserContext) {
|
||||
writer.trimLeadingSpaces = false;
|
||||
writer.writeLine(collector.workingPackage.tsdocParserContext.sourceRange.toString());
|
||||
writer.trimLeadingSpaces = true;
|
||||
writer.ensureSkippedLine();
|
||||
}
|
||||
|
||||
// Emit the triple slash directives
|
||||
for (const typeDirectiveReference of collector.dtsTypeReferenceDirectives) {
|
||||
// https://github.com/microsoft/TypeScript/blob/611ebc7aadd7a44a4c0447698bfda9222a78cb66/src/compiler/declarationEmitter.ts#L162
|
||||
writer.writeLine(`/// <reference types="${typeDirectiveReference}" />`);
|
||||
}
|
||||
|
||||
for (const libDirectiveReference of collector.dtsLibReferenceDirectives) {
|
||||
writer.writeLine(`/// <reference lib="${libDirectiveReference}" />`);
|
||||
}
|
||||
|
||||
writer.ensureSkippedLine();
|
||||
|
||||
// Emit the imports
|
||||
for (const entity of collector.entities) {
|
||||
if (entity.astEntity instanceof AstImport) {
|
||||
const astImport: AstImport = entity.astEntity;
|
||||
|
||||
// For example, if the imported API comes from an external package that supports AEDoc,
|
||||
// and it was marked as `@internal`, then don't emit it.
|
||||
const symbolMetadata: SymbolMetadata | undefined = collector.tryFetchMetadataForAstEntity(astImport);
|
||||
const maxEffectiveReleaseTag: ReleaseTag = symbolMetadata
|
||||
? symbolMetadata.maxEffectiveReleaseTag
|
||||
: ReleaseTag.None;
|
||||
|
||||
if (this._shouldIncludeReleaseTag(maxEffectiveReleaseTag, dtsKind)) {
|
||||
DtsEmitHelpers.emitImport(writer, entity, astImport);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writer.ensureSkippedLine();
|
||||
|
||||
// Emit the regular declarations
|
||||
for (const entity of collector.entities) {
|
||||
const astEntity: AstEntity = entity.astEntity;
|
||||
const symbolMetadata: SymbolMetadata | undefined = collector.tryFetchMetadataForAstEntity(astEntity);
|
||||
const maxEffectiveReleaseTag: ReleaseTag = symbolMetadata
|
||||
? symbolMetadata.maxEffectiveReleaseTag
|
||||
: ReleaseTag.None;
|
||||
|
||||
if (!this._shouldIncludeReleaseTag(maxEffectiveReleaseTag, dtsKind)) {
|
||||
if (!collector.extractorConfig.omitTrimmingComments) {
|
||||
writer.ensureSkippedLine();
|
||||
writer.writeLine(`/* Excluded from this release type: ${entity.nameForEmit} */`);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (astEntity instanceof AstSymbol) {
|
||||
// Emit all the declarations for this entry
|
||||
for (const astDeclaration of astEntity.astDeclarations || []) {
|
||||
const apiItemMetadata: ApiItemMetadata = collector.fetchApiItemMetadata(astDeclaration);
|
||||
|
||||
if (this._shouldIncludeReleaseTag(apiItemMetadata.effectiveReleaseTag, dtsKind)) {
|
||||
const span: Span = new Span(astDeclaration.declaration);
|
||||
DtsRollupGenerator._modifySpan(collector, span, entity, astDeclaration, dtsKind);
|
||||
writer.ensureSkippedLine();
|
||||
span.writeModifiedText(writer);
|
||||
writer.ensureNewLine();
|
||||
} else if (!collector.extractorConfig.omitTrimmingComments) {
|
||||
writer.ensureSkippedLine();
|
||||
writer.writeLine(`/* Excluded declaration from this release type: ${entity.nameForEmit} */`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (astEntity instanceof AstNamespaceImport) {
|
||||
const astModuleExportInfo: AstModuleExportInfo = astEntity.fetchAstModuleExportInfo(collector);
|
||||
|
||||
if (entity.nameForEmit === undefined) {
|
||||
// This should never happen
|
||||
throw new InternalError('referencedEntry.nameForEmit is undefined');
|
||||
}
|
||||
|
||||
if (astModuleExportInfo.starExportedExternalModules.size > 0) {
|
||||
// We could support this, but we would need to find a way to safely represent it.
|
||||
throw new Error(
|
||||
`The ${entity.nameForEmit} namespace import includes a start export, which is not supported:\n` +
|
||||
SourceFileLocationFormatter.formatDeclaration(astEntity.declaration),
|
||||
);
|
||||
}
|
||||
|
||||
// Emit a synthetic declaration for the namespace. It will look like this:
|
||||
//
|
||||
// declare namespace example {
|
||||
// export {
|
||||
// f1,
|
||||
// f2
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Note that we do not try to relocate f1()/f2() to be inside the namespace because other type
|
||||
// signatures may reference them directly (without using the namespace qualifier).
|
||||
|
||||
writer.ensureSkippedLine();
|
||||
if (entity.shouldInlineExport) {
|
||||
writer.write('export ');
|
||||
}
|
||||
|
||||
writer.writeLine(`declare namespace ${entity.nameForEmit} {`);
|
||||
|
||||
// all local exports of local imported module are just references to top-level declarations
|
||||
writer.increaseIndent();
|
||||
writer.writeLine('export {');
|
||||
writer.increaseIndent();
|
||||
|
||||
const exportClauses: string[] = [];
|
||||
for (const [exportedName, exportedEntity] of astModuleExportInfo.exportedLocalEntities) {
|
||||
const collectorEntity: CollectorEntity | undefined = collector.tryGetCollectorEntity(exportedEntity);
|
||||
if (collectorEntity === undefined) {
|
||||
// This should never happen
|
||||
// top-level exports of local imported module should be added as collector entities before
|
||||
throw new InternalError(
|
||||
`Cannot find collector entity for ${entity.nameForEmit}.${exportedEntity.localName}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (collectorEntity.nameForEmit === exportedName) {
|
||||
exportClauses.push(collectorEntity.nameForEmit);
|
||||
} else {
|
||||
exportClauses.push(`${collectorEntity.nameForEmit} as ${exportedName}`);
|
||||
}
|
||||
}
|
||||
|
||||
writer.writeLine(exportClauses.join(',\n'));
|
||||
|
||||
writer.decreaseIndent();
|
||||
writer.writeLine('}'); // end of "export { ... }"
|
||||
writer.decreaseIndent();
|
||||
writer.writeLine('}'); // end of "declare namespace { ... }"
|
||||
}
|
||||
|
||||
if (!entity.shouldInlineExport) {
|
||||
for (const exportName of entity.exportNames) {
|
||||
DtsEmitHelpers.emitNamedExport(writer, exportName, entity);
|
||||
}
|
||||
}
|
||||
|
||||
writer.ensureSkippedLine();
|
||||
}
|
||||
|
||||
DtsEmitHelpers.emitStarExports(writer, collector);
|
||||
|
||||
// Emit "export { }" which is a special directive that prevents consumers from importing declarations
|
||||
// that don't have an explicit "export" modifier.
|
||||
writer.ensureSkippedLine();
|
||||
writer.writeLine('export { }');
|
||||
}
|
||||
|
||||
/**
|
||||
* Before writing out a declaration, _modifySpan() applies various fixups to make it nice.
|
||||
*/
|
||||
private static _modifySpan(
|
||||
collector: Collector,
|
||||
span: Span,
|
||||
entity: CollectorEntity,
|
||||
astDeclaration: AstDeclaration,
|
||||
dtsKind: DtsRollupKind,
|
||||
): void {
|
||||
const previousSpan: Span | undefined = span.previousSibling;
|
||||
|
||||
let recurseChildren = true;
|
||||
switch (span.kind) {
|
||||
case ts.SyntaxKind.JSDocComment:
|
||||
// If the @packageDocumentation comment seems to be attached to one of the regular API items,
|
||||
// omit it. It gets explictly emitted at the top of the file.
|
||||
if (/[\s*]@packagedocumentation[\s*]/gi.test(span.node.getText())) {
|
||||
span.modification.skipAll();
|
||||
}
|
||||
|
||||
// For now, we don't transform JSDoc comment nodes at all
|
||||
recurseChildren = false;
|
||||
break;
|
||||
|
||||
case ts.SyntaxKind.ExportKeyword:
|
||||
case ts.SyntaxKind.DefaultKeyword:
|
||||
case ts.SyntaxKind.DeclareKeyword:
|
||||
// Delete any explicit "export" or "declare" keywords -- we will re-add them below
|
||||
span.modification.skipAll();
|
||||
break;
|
||||
|
||||
case ts.SyntaxKind.InterfaceKeyword:
|
||||
case ts.SyntaxKind.ClassKeyword:
|
||||
case ts.SyntaxKind.EnumKeyword:
|
||||
case ts.SyntaxKind.NamespaceKeyword:
|
||||
case ts.SyntaxKind.ModuleKeyword:
|
||||
case ts.SyntaxKind.TypeKeyword:
|
||||
case ts.SyntaxKind.FunctionKeyword:
|
||||
// Replace the stuff we possibly deleted above
|
||||
let replacedModifiers = '';
|
||||
|
||||
// Add a declare statement for root declarations (but not for nested declarations)
|
||||
if (!astDeclaration.parent) {
|
||||
replacedModifiers += 'declare ';
|
||||
}
|
||||
|
||||
if (entity.shouldInlineExport) {
|
||||
replacedModifiers = 'export ' + replacedModifiers;
|
||||
}
|
||||
|
||||
if (previousSpan && previousSpan.kind === ts.SyntaxKind.SyntaxList) {
|
||||
// If there is a previous span of type SyntaxList, then apply it before any other modifiers
|
||||
// (e.g. "abstract") that appear there.
|
||||
previousSpan.modification.prefix = replacedModifiers + previousSpan.modification.prefix;
|
||||
} else {
|
||||
// Otherwise just stick it in front of this span
|
||||
span.modification.prefix = replacedModifiers + span.modification.prefix;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case ts.SyntaxKind.VariableDeclaration:
|
||||
// Is this a top-level variable declaration?
|
||||
// (The logic below does not apply to variable declarations that are part of an explicit "namespace" block,
|
||||
// since the compiler prefers not to emit "declare" or "export" keywords for those declarations.)
|
||||
if (!span.parent) {
|
||||
// The VariableDeclaration node is part of a VariableDeclarationList, however
|
||||
// the Entry.followedSymbol points to the VariableDeclaration part because
|
||||
// multiple definitions might share the same VariableDeclarationList.
|
||||
//
|
||||
// Since we are emitting a separate declaration for each one, we need to look upwards
|
||||
// in the ts.Node tree and write a copy of the enclosing VariableDeclarationList
|
||||
// content (e.g. "var" from "var x=1, y=2").
|
||||
const list: ts.VariableDeclarationList | undefined = TypeScriptHelpers.matchAncestor(span.node, [
|
||||
ts.SyntaxKind.VariableDeclarationList,
|
||||
ts.SyntaxKind.VariableDeclaration,
|
||||
]);
|
||||
if (!list) {
|
||||
// This should not happen unless the compiler API changes somehow
|
||||
throw new InternalError('Unsupported variable declaration');
|
||||
}
|
||||
|
||||
const listPrefix: string = list.getSourceFile().text.slice(list.getStart(), list.declarations[0]!.getStart());
|
||||
span.modification.prefix = 'declare ' + listPrefix + span.modification.prefix;
|
||||
span.modification.suffix = ';';
|
||||
|
||||
if (entity.shouldInlineExport) {
|
||||
span.modification.prefix = 'export ' + span.modification.prefix;
|
||||
}
|
||||
|
||||
const declarationMetadata: DeclarationMetadata = collector.fetchDeclarationMetadata(astDeclaration);
|
||||
if (declarationMetadata.tsdocParserContext) {
|
||||
// Typically the comment for a variable declaration is attached to the outer variable statement
|
||||
// (which may possibly contain multiple variable declarations), so it's not part of the Span.
|
||||
// Instead we need to manually inject it.
|
||||
let originalComment: string = declarationMetadata.tsdocParserContext.sourceRange.toString();
|
||||
if (!/\r?\n\s*$/.test(originalComment)) {
|
||||
originalComment += '\n';
|
||||
}
|
||||
|
||||
span.modification.indentDocComment = IndentDocCommentScope.PrefixOnly;
|
||||
span.modification.prefix = originalComment + span.modification.prefix;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case ts.SyntaxKind.Identifier:
|
||||
{
|
||||
const referencedEntity: CollectorEntity | undefined = collector.tryGetEntityForNode(
|
||||
span.node as ts.Identifier,
|
||||
);
|
||||
|
||||
if (referencedEntity) {
|
||||
if (!referencedEntity.nameForEmit) {
|
||||
// This should never happen
|
||||
throw new InternalError('referencedEntry.nameForEmit is undefined');
|
||||
}
|
||||
|
||||
span.modification.prefix = referencedEntity.nameForEmit;
|
||||
// For debugging:
|
||||
// span.modification.prefix += '/*R=FIX*/';
|
||||
} else {
|
||||
// For debugging:
|
||||
// span.modification.prefix += '/*R=KEEP*/';
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case ts.SyntaxKind.ImportType:
|
||||
DtsEmitHelpers.modifyImportTypeSpan(collector, span, astDeclaration, (childSpan, childAstDeclaration) => {
|
||||
DtsRollupGenerator._modifySpan(collector, childSpan, entity, childAstDeclaration, dtsKind);
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (recurseChildren) {
|
||||
for (const child of span.children) {
|
||||
let childAstDeclaration: AstDeclaration = astDeclaration;
|
||||
|
||||
// Should we trim this node?
|
||||
let trimmed = false;
|
||||
if (AstDeclaration.isSupportedSyntaxKind(child.kind)) {
|
||||
childAstDeclaration = collector.astSymbolTable.getChildAstDeclarationByNode(child.node, astDeclaration);
|
||||
const releaseTag: ReleaseTag = collector.fetchApiItemMetadata(childAstDeclaration).effectiveReleaseTag;
|
||||
|
||||
if (!this._shouldIncludeReleaseTag(releaseTag, dtsKind)) {
|
||||
let nodeToTrim: Span = child;
|
||||
|
||||
// If we are trimming a variable statement, then we need to trim the outer VariableDeclarationList
|
||||
// as well.
|
||||
if (child.kind === ts.SyntaxKind.VariableDeclaration) {
|
||||
const variableStatement: Span | undefined = child.findFirstParent(ts.SyntaxKind.VariableStatement);
|
||||
if (variableStatement !== undefined) {
|
||||
nodeToTrim = variableStatement;
|
||||
}
|
||||
}
|
||||
|
||||
const modification: SpanModification = nodeToTrim.modification;
|
||||
|
||||
// Yes, trim it and stop here
|
||||
const name: string = childAstDeclaration.astSymbol.localName;
|
||||
modification.omitChildren = true;
|
||||
|
||||
if (collector.extractorConfig.omitTrimmingComments) {
|
||||
modification.prefix = '';
|
||||
} else {
|
||||
modification.prefix = `/* Excluded from this release type: ${name} */`;
|
||||
}
|
||||
|
||||
modification.suffix = '';
|
||||
|
||||
if (nodeToTrim.children.length > 0) {
|
||||
// If there are grandchildren, then keep the last grandchild's separator,
|
||||
// since it often has useful whitespace
|
||||
modification.suffix = nodeToTrim.children[nodeToTrim.children.length - 1]!.separator;
|
||||
}
|
||||
|
||||
if (
|
||||
nodeToTrim.nextSibling && // If the thing we are trimming is followed by a comma, then trim the comma also.
|
||||
// An example would be an enum member.
|
||||
nodeToTrim.nextSibling.kind === ts.SyntaxKind.CommaToken
|
||||
) {
|
||||
// Keep its separator since it often has useful whitespace
|
||||
modification.suffix += nodeToTrim.nextSibling.separator;
|
||||
nodeToTrim.nextSibling.modification.skipAll();
|
||||
}
|
||||
|
||||
trimmed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!trimmed) {
|
||||
DtsRollupGenerator._modifySpan(collector, child, entity, childAstDeclaration, dtsKind);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static _shouldIncludeReleaseTag(releaseTag: ReleaseTag, dtsKind: DtsRollupKind): boolean {
|
||||
switch (dtsKind) {
|
||||
case DtsRollupKind.InternalRelease:
|
||||
return true;
|
||||
case DtsRollupKind.AlphaRelease:
|
||||
return (
|
||||
releaseTag === ReleaseTag.Alpha ||
|
||||
releaseTag === ReleaseTag.Beta ||
|
||||
releaseTag === ReleaseTag.Public ||
|
||||
// NOTE: If the release tag is "None", then we don't have enough information to trim it
|
||||
releaseTag === ReleaseTag.None
|
||||
);
|
||||
case DtsRollupKind.BetaRelease:
|
||||
return (
|
||||
releaseTag === ReleaseTag.Beta ||
|
||||
releaseTag === ReleaseTag.Public ||
|
||||
// NOTE: If the release tag is "None", then we don't have enough information to trim it
|
||||
releaseTag === ReleaseTag.None
|
||||
);
|
||||
case DtsRollupKind.PublicRelease:
|
||||
return releaseTag === ReleaseTag.Public || releaseTag === ReleaseTag.None;
|
||||
default:
|
||||
throw new Error(`${DtsRollupKind[dtsKind]} is not implemented`);
|
||||
}
|
||||
}
|
||||
}
|
||||
338
packages/api-extractor/src/generators/ExcerptBuilder.ts
Normal file
338
packages/api-extractor/src/generators/ExcerptBuilder.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
import { ExcerptTokenKind, type IExcerptToken, type IExcerptTokenRange } from '@discordjs/api-extractor-model';
|
||||
import type { DeclarationReference } from '@microsoft/tsdoc/lib-commonjs/beta/DeclarationReference';
|
||||
import * as ts from 'typescript';
|
||||
import type { AstDeclaration } from '../analyzer/AstDeclaration.js';
|
||||
import { Span } from '../analyzer/Span.js';
|
||||
import type { DeclarationReferenceGenerator } from './DeclarationReferenceGenerator.js';
|
||||
|
||||
/**
|
||||
* Used to provide ExcerptBuilder with a list of nodes whose token range we want to capture.
|
||||
*/
|
||||
export interface IExcerptBuilderNodeToCapture {
|
||||
/**
|
||||
* The node to capture
|
||||
*/
|
||||
node: ts.Node | undefined;
|
||||
/**
|
||||
* The token range whose startIndex/endIndex will be overwritten with the indexes for the
|
||||
* tokens corresponding to IExcerptBuilderNodeToCapture.node
|
||||
*/
|
||||
tokenRange: IExcerptTokenRange;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal state for ExcerptBuilder
|
||||
*/
|
||||
interface IBuildSpanState {
|
||||
/**
|
||||
* Tracks whether the last appended token was a separator. If so, and we're in the middle of
|
||||
* capturing a token range, then omit the separator from the range.
|
||||
*/
|
||||
lastAppendedTokenIsSeparator: boolean;
|
||||
|
||||
referenceGenerator: DeclarationReferenceGenerator;
|
||||
|
||||
/**
|
||||
* The AST node that we will traverse to extract tokens
|
||||
*/
|
||||
startingNode: ts.Node;
|
||||
|
||||
/**
|
||||
* Normally, the excerpt will include all child nodes for `startingNode`; whereas if `childKindToStopBefore`
|
||||
* is specified, then the node traversal will stop before (i.e. excluding) the first immediate child
|
||||
* of `startingNode` with the specified syntax kind.
|
||||
*
|
||||
* @remarks
|
||||
* For example, suppose the signature is `interface X: Y { z: string }`. The token `{` has syntax kind
|
||||
* `ts.SyntaxKind.FirstPunctuation`, so we can specify that to truncate the excerpt to `interface X: Y`.
|
||||
*/
|
||||
stopBeforeChildKind: ts.SyntaxKind | undefined;
|
||||
|
||||
tokenRangesByNode: Map<ts.Node, IExcerptTokenRange>;
|
||||
}
|
||||
|
||||
export class ExcerptBuilder {
|
||||
/**
|
||||
* Appends a blank line to the `excerptTokens` list.
|
||||
*
|
||||
* @param excerptTokens - The target token list to append to
|
||||
*/
|
||||
public static addBlankLine(excerptTokens: IExcerptToken[]): void {
|
||||
let newlines = '\n\n';
|
||||
// If the existing text already ended with a newline, then only append one newline
|
||||
if (excerptTokens.length > 0) {
|
||||
const previousText: string = excerptTokens[excerptTokens.length - 1]!.text;
|
||||
if (previousText.endsWith('\n')) {
|
||||
newlines = '\n';
|
||||
}
|
||||
}
|
||||
|
||||
excerptTokens.push({ kind: ExcerptTokenKind.Content, text: newlines });
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends the signature for the specified `AstDeclaration` to the `excerptTokens` list.
|
||||
*
|
||||
* @param excerptTokens - The target token list to append to
|
||||
* @param astDeclaration - The declaration
|
||||
* @param nodesToCapture - A list of child nodes whose token ranges we want to capture
|
||||
*/
|
||||
public static addDeclaration(
|
||||
excerptTokens: IExcerptToken[],
|
||||
astDeclaration: AstDeclaration,
|
||||
nodesToCapture: IExcerptBuilderNodeToCapture[],
|
||||
referenceGenerator: DeclarationReferenceGenerator,
|
||||
): void {
|
||||
let stopBeforeChildKind: ts.SyntaxKind | undefined;
|
||||
|
||||
switch (astDeclaration.declaration.kind) {
|
||||
case ts.SyntaxKind.ClassDeclaration:
|
||||
case ts.SyntaxKind.EnumDeclaration:
|
||||
case ts.SyntaxKind.InterfaceDeclaration:
|
||||
// FirstPunctuation = "{"
|
||||
stopBeforeChildKind = ts.SyntaxKind.FirstPunctuation;
|
||||
break;
|
||||
case ts.SyntaxKind.ModuleDeclaration:
|
||||
// ModuleBlock = the "{ ... }" block
|
||||
stopBeforeChildKind = ts.SyntaxKind.ModuleBlock;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
const span: Span = new Span(astDeclaration.declaration);
|
||||
|
||||
const tokenRangesByNode: Map<ts.Node, IExcerptTokenRange> = new Map<ts.Node, IExcerptTokenRange>();
|
||||
for (const excerpt of nodesToCapture || []) {
|
||||
if (excerpt.node) {
|
||||
tokenRangesByNode.set(excerpt.node, excerpt.tokenRange);
|
||||
}
|
||||
}
|
||||
|
||||
ExcerptBuilder._buildSpan(excerptTokens, span, {
|
||||
referenceGenerator,
|
||||
startingNode: span.node,
|
||||
stopBeforeChildKind,
|
||||
tokenRangesByNode,
|
||||
lastAppendedTokenIsSeparator: false,
|
||||
});
|
||||
ExcerptBuilder._condenseTokens(excerptTokens, [...tokenRangesByNode.values()]);
|
||||
}
|
||||
|
||||
public static createEmptyTokenRange(): IExcerptTokenRange {
|
||||
return { startIndex: 0, endIndex: 0 };
|
||||
}
|
||||
|
||||
private static _buildSpan(excerptTokens: IExcerptToken[], span: Span, state: IBuildSpanState): boolean {
|
||||
if (span.kind === ts.SyntaxKind.JSDocComment) {
|
||||
// Discard any comments
|
||||
return true;
|
||||
}
|
||||
|
||||
// Can this node start a excerpt?
|
||||
const capturedTokenRange: IExcerptTokenRange | undefined = state.tokenRangesByNode.get(span.node);
|
||||
let excerptStartIndex = 0;
|
||||
|
||||
if (capturedTokenRange) {
|
||||
// We will assign capturedTokenRange.startIndex to be the index of the next token to be appended
|
||||
excerptStartIndex = excerptTokens.length;
|
||||
}
|
||||
|
||||
if (span.prefix) {
|
||||
let canonicalReference: DeclarationReference | undefined;
|
||||
|
||||
if (span.kind === ts.SyntaxKind.Identifier) {
|
||||
const name: ts.Identifier = span.node as ts.Identifier;
|
||||
if (!ExcerptBuilder._isDeclarationName(name)) {
|
||||
canonicalReference = state.referenceGenerator.getDeclarationReferenceForIdentifier(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (canonicalReference) {
|
||||
ExcerptBuilder._appendToken(excerptTokens, ExcerptTokenKind.Reference, span.prefix, canonicalReference);
|
||||
} else {
|
||||
ExcerptBuilder._appendToken(excerptTokens, ExcerptTokenKind.Content, span.prefix);
|
||||
}
|
||||
|
||||
state.lastAppendedTokenIsSeparator = false;
|
||||
}
|
||||
|
||||
for (const child of span.children) {
|
||||
if (span.node === state.startingNode && state.stopBeforeChildKind && child.kind === state.stopBeforeChildKind) {
|
||||
// We reached a child whose kind is stopBeforeChildKind, so stop traversing
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this._buildSpan(excerptTokens, child, state)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (span.suffix) {
|
||||
ExcerptBuilder._appendToken(excerptTokens, ExcerptTokenKind.Content, span.suffix);
|
||||
state.lastAppendedTokenIsSeparator = false;
|
||||
}
|
||||
|
||||
if (span.separator) {
|
||||
ExcerptBuilder._appendToken(excerptTokens, ExcerptTokenKind.Content, span.separator);
|
||||
state.lastAppendedTokenIsSeparator = true;
|
||||
}
|
||||
|
||||
// Are we building a excerpt? If so, set its range
|
||||
if (capturedTokenRange) {
|
||||
capturedTokenRange.startIndex = excerptStartIndex;
|
||||
|
||||
// We will assign capturedTokenRange.startIndex to be the index after the last token
|
||||
// that was appended so far. However, if the last appended token was a separator, omit
|
||||
// it from the range.
|
||||
let excerptEndIndex: number = excerptTokens.length;
|
||||
if (state.lastAppendedTokenIsSeparator) {
|
||||
excerptEndIndex--;
|
||||
}
|
||||
|
||||
capturedTokenRange.endIndex = excerptEndIndex;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static _appendToken(
|
||||
excerptTokens: IExcerptToken[],
|
||||
excerptTokenKind: ExcerptTokenKind,
|
||||
text: string,
|
||||
canonicalReference?: DeclarationReference,
|
||||
): void {
|
||||
if (text.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const excerptToken: IExcerptToken = { kind: excerptTokenKind, text };
|
||||
if (canonicalReference !== undefined) {
|
||||
excerptToken.canonicalReference = canonicalReference.toString();
|
||||
}
|
||||
|
||||
excerptTokens.push(excerptToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Condenses the provided excerpt tokens by merging tokens where possible. Updates the provided token ranges to
|
||||
* remain accurate after token merging.
|
||||
*
|
||||
* @remarks
|
||||
* For example, suppose we have excerpt tokens ["A", "B", "C"] and a token range [0, 2]. If the excerpt tokens
|
||||
* are condensed to ["AB", "C"], then the token range would be updated to [0, 1]. Note that merges are only
|
||||
* performed if they are compatible with the provided token ranges. In the example above, if our token range was
|
||||
* originally [0, 1], we would not be able to merge tokens "A" and "B".
|
||||
*/
|
||||
private static _condenseTokens(excerptTokens: IExcerptToken[], tokenRanges: IExcerptTokenRange[]): void {
|
||||
// This set is used to quickly lookup a start or end index.
|
||||
const startOrEndIndices: Set<number> = new Set();
|
||||
for (const tokenRange of tokenRanges) {
|
||||
startOrEndIndices.add(tokenRange.startIndex);
|
||||
startOrEndIndices.add(tokenRange.endIndex);
|
||||
}
|
||||
|
||||
for (let currentIndex = 1; currentIndex < excerptTokens.length; ++currentIndex) {
|
||||
while (currentIndex < excerptTokens.length) {
|
||||
const prevPrevToken: IExcerptToken | undefined = excerptTokens[currentIndex - 2]; // May be undefined
|
||||
const prevToken: IExcerptToken = excerptTokens[currentIndex - 1]!;
|
||||
const currentToken: IExcerptToken = excerptTokens[currentIndex]!;
|
||||
|
||||
// The number of excerpt tokens that are merged in this iteration. We need this to determine
|
||||
// how to update the start and end indices of our token ranges.
|
||||
let mergeCount: number;
|
||||
|
||||
// There are two types of merges that can occur. We only perform these merges if they are
|
||||
// compatible with all of our token ranges.
|
||||
if (
|
||||
prevPrevToken &&
|
||||
prevPrevToken.kind === ExcerptTokenKind.Reference &&
|
||||
prevToken.kind === ExcerptTokenKind.Content &&
|
||||
prevToken.text.trim() === '.' &&
|
||||
currentToken.kind === ExcerptTokenKind.Reference &&
|
||||
!startOrEndIndices.has(currentIndex) &&
|
||||
!startOrEndIndices.has(currentIndex - 1)
|
||||
) {
|
||||
// If the current token is a reference token, the previous token is a ".", and the previous-
|
||||
// previous token is a reference token, then merge all three tokens into a reference token.
|
||||
//
|
||||
// For example: Given ["MyNamespace" (R), ".", "MyClass" (R)], tokens "." and "MyClass" might
|
||||
// be merged into "MyNamespace". The condensed token would be ["MyNamespace.MyClass" (R)].
|
||||
prevPrevToken.text += prevToken.text + currentToken.text;
|
||||
prevPrevToken.canonicalReference = currentToken.canonicalReference;
|
||||
mergeCount = 2;
|
||||
currentIndex--;
|
||||
} else if (
|
||||
// If the current and previous tokens are both content tokens, then merge the tokens into a
|
||||
// single content token. For example: Given ["export ", "declare class"], these tokens
|
||||
// might be merged into "export declare class".
|
||||
prevToken.kind === ExcerptTokenKind.Content &&
|
||||
prevToken.kind === currentToken.kind &&
|
||||
!startOrEndIndices.has(currentIndex)
|
||||
) {
|
||||
prevToken.text += currentToken.text;
|
||||
mergeCount = 1;
|
||||
} else {
|
||||
// Otherwise, no merging can occur here. Continue to the next index.
|
||||
break;
|
||||
}
|
||||
|
||||
// Remove the now redundant excerpt token(s), as they were merged into a previous token.
|
||||
excerptTokens.splice(currentIndex, mergeCount);
|
||||
|
||||
// Update the start and end indices for all token ranges based upon how many excerpt
|
||||
// tokens were merged and in what positions.
|
||||
for (const tokenRange of tokenRanges) {
|
||||
if (tokenRange.startIndex > currentIndex) {
|
||||
tokenRange.startIndex -= mergeCount;
|
||||
}
|
||||
|
||||
if (tokenRange.endIndex > currentIndex) {
|
||||
tokenRange.endIndex -= mergeCount;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear and repopulate our set with the updated indices.
|
||||
startOrEndIndices.clear();
|
||||
for (const tokenRange of tokenRanges) {
|
||||
startOrEndIndices.add(tokenRange.startIndex);
|
||||
startOrEndIndices.add(tokenRange.endIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static _isDeclarationName(name: ts.Identifier): boolean {
|
||||
return ExcerptBuilder._isDeclaration(name.parent) && name.parent.name === name;
|
||||
}
|
||||
|
||||
private static _isDeclaration(node: ts.Node): node is ts.NamedDeclaration {
|
||||
switch (node.kind) {
|
||||
case ts.SyntaxKind.FunctionDeclaration:
|
||||
case ts.SyntaxKind.FunctionExpression:
|
||||
case ts.SyntaxKind.VariableDeclaration:
|
||||
case ts.SyntaxKind.Parameter:
|
||||
case ts.SyntaxKind.EnumDeclaration:
|
||||
case ts.SyntaxKind.ClassDeclaration:
|
||||
case ts.SyntaxKind.ClassExpression:
|
||||
case ts.SyntaxKind.ModuleDeclaration:
|
||||
case ts.SyntaxKind.MethodDeclaration:
|
||||
case ts.SyntaxKind.MethodSignature:
|
||||
case ts.SyntaxKind.PropertyDeclaration:
|
||||
case ts.SyntaxKind.PropertySignature:
|
||||
case ts.SyntaxKind.GetAccessor:
|
||||
case ts.SyntaxKind.SetAccessor:
|
||||
case ts.SyntaxKind.InterfaceDeclaration:
|
||||
case ts.SyntaxKind.TypeAliasDeclaration:
|
||||
case ts.SyntaxKind.TypeParameter:
|
||||
case ts.SyntaxKind.EnumMember:
|
||||
case ts.SyntaxKind.BindingElement:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
281
packages/api-extractor/src/generators/IndentedWriter.ts
Normal file
281
packages/api-extractor/src/generators/IndentedWriter.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
import { StringBuilder, type IStringBuilder } from '@rushstack/node-core-library';
|
||||
|
||||
/**
|
||||
* A utility for writing indented text.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* Note that the indentation is inserted at the last possible opportunity.
|
||||
* For example, this code...
|
||||
*
|
||||
* ```ts
|
||||
* writer.write('begin\n');
|
||||
* writer.increaseIndent();
|
||||
* writer.write('one\ntwo\n');
|
||||
* writer.decreaseIndent();
|
||||
* writer.increaseIndent();
|
||||
* writer.decreaseIndent();
|
||||
* writer.write('end');
|
||||
* ```
|
||||
*
|
||||
* ...would produce this output:
|
||||
*
|
||||
* ```
|
||||
* begin
|
||||
* one
|
||||
* two
|
||||
* end
|
||||
* ```
|
||||
*/
|
||||
export class IndentedWriter {
|
||||
/**
|
||||
* The text characters used to create one level of indentation.
|
||||
* Two spaces by default.
|
||||
*/
|
||||
public defaultIndentPrefix: string = ' ';
|
||||
|
||||
/**
|
||||
* Whether to indent blank lines
|
||||
*/
|
||||
public indentBlankLines: boolean = false;
|
||||
|
||||
/**
|
||||
* Trims leading spaces from the input text before applying the indent.
|
||||
*
|
||||
* @remarks
|
||||
* Consider the following example:
|
||||
*
|
||||
* ```ts
|
||||
* indentedWriter.increaseIndent(' '); // four spaces
|
||||
* indentedWriter.write(' a\n b c\n');
|
||||
* indentedWriter.decreaseIndent();
|
||||
* ```
|
||||
*
|
||||
* Normally the output would be indented by 6 spaces: 4 from `increaseIndent()`, plus the 2 spaces
|
||||
* from `write()`:
|
||||
* ```
|
||||
* a
|
||||
* b c
|
||||
* ```
|
||||
*
|
||||
* Setting `trimLeadingSpaces=true` will trim the leading spaces, so that the lines are indented
|
||||
* by 4 spaces only:
|
||||
* ```
|
||||
* a
|
||||
* b c
|
||||
* ```
|
||||
*/
|
||||
public trimLeadingSpaces: boolean = false;
|
||||
|
||||
private readonly _builder: IStringBuilder;
|
||||
|
||||
private _latestChunk: string | undefined;
|
||||
|
||||
private _previousChunk: string | undefined;
|
||||
|
||||
private _atStartOfLine: boolean;
|
||||
|
||||
private readonly _indentStack: string[];
|
||||
|
||||
private _indentText: string;
|
||||
|
||||
private _previousLineIsBlank: boolean;
|
||||
|
||||
private _currentLineIsBlank: boolean;
|
||||
|
||||
public constructor(builder?: IStringBuilder) {
|
||||
this._builder = builder ?? new StringBuilder();
|
||||
this._latestChunk = undefined;
|
||||
this._previousChunk = undefined;
|
||||
this._atStartOfLine = true;
|
||||
this._previousLineIsBlank = true;
|
||||
this._currentLineIsBlank = true;
|
||||
|
||||
this._indentStack = [];
|
||||
this._indentText = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the output that was built so far.
|
||||
*/
|
||||
public getText(): string {
|
||||
return this._builder.toString();
|
||||
}
|
||||
|
||||
public toString(): string {
|
||||
return this.getText();
|
||||
}
|
||||
|
||||
/**
|
||||
* Increases the indentation. Normally the indentation is two spaces,
|
||||
* however an arbitrary prefix can optional be specified. (For example,
|
||||
* the prefix could be "// " to indent and comment simultaneously.)
|
||||
* Each call to IndentedWriter.increaseIndent() must be followed by a
|
||||
* corresponding call to IndentedWriter.decreaseIndent().
|
||||
*/
|
||||
public increaseIndent(indentPrefix?: string): void {
|
||||
this._indentStack.push(indentPrefix ?? this.defaultIndentPrefix);
|
||||
this._updateIndentText();
|
||||
}
|
||||
|
||||
/**
|
||||
* Decreases the indentation, reverting the effect of the corresponding call
|
||||
* to IndentedWriter.increaseIndent().
|
||||
*/
|
||||
public decreaseIndent(): void {
|
||||
this._indentStack.pop();
|
||||
this._updateIndentText();
|
||||
}
|
||||
|
||||
/**
|
||||
* A shorthand for ensuring that increaseIndent()/decreaseIndent() occur
|
||||
* in pairs.
|
||||
*/
|
||||
public indentScope(scope: () => void, indentPrefix?: string): void {
|
||||
this.increaseIndent(indentPrefix);
|
||||
scope();
|
||||
this.decreaseIndent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a newline if the file pointer is not already at the start of the line (or start of the stream).
|
||||
*/
|
||||
public ensureNewLine(): void {
|
||||
const lastCharacter: string = this.peekLastCharacter();
|
||||
if (lastCharacter !== '\n' && lastCharacter !== '') {
|
||||
this._writeNewLine();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds up to two newlines to ensure that there is a blank line above the current position.
|
||||
* The start of the stream is considered to be a blank line, so `ensureSkippedLine()` has no effect
|
||||
* unless some text has been written.
|
||||
*/
|
||||
public ensureSkippedLine(): void {
|
||||
this.ensureNewLine();
|
||||
if (!this._previousLineIsBlank) {
|
||||
this._writeNewLine();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the last character that was written, or an empty string if no characters have been written yet.
|
||||
*/
|
||||
public peekLastCharacter(): string {
|
||||
if (this._latestChunk !== undefined) {
|
||||
return this._latestChunk.slice(-1, -1 + 1);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the second to last character that was written, or an empty string if less than one characters
|
||||
* have been written yet.
|
||||
*/
|
||||
public peekSecondLastCharacter(): string {
|
||||
if (this._latestChunk !== undefined) {
|
||||
if (this._latestChunk.length > 1) {
|
||||
return this._latestChunk.slice(-2, -2 + 1);
|
||||
}
|
||||
|
||||
if (this._previousChunk !== undefined) {
|
||||
return this._previousChunk.slice(-1, -1 + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes some text to the internal string buffer, applying indentation according
|
||||
* to the current indentation level. If the string contains multiple newlines,
|
||||
* each line will be indented separately.
|
||||
*/
|
||||
public write(message: string): void {
|
||||
if (message.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If there are no newline characters, then append the string verbatim
|
||||
if (!/[\n\r]/.test(message)) {
|
||||
this._writeLinePart(message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise split the lines and write each one individually
|
||||
let first = true;
|
||||
for (const linePart of message.split('\n')) {
|
||||
if (first) {
|
||||
first = false;
|
||||
} else {
|
||||
this._writeNewLine();
|
||||
}
|
||||
|
||||
if (linePart) {
|
||||
this._writeLinePart(linePart.replaceAll('\r', ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A shorthand for writing an optional message, followed by a newline.
|
||||
* Indentation is applied following the semantics of IndentedWriter.write().
|
||||
*/
|
||||
public writeLine(message: string = ''): void {
|
||||
if (message.length > 0) {
|
||||
this.write(message);
|
||||
}
|
||||
|
||||
this._writeNewLine();
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a string that does not contain any newline characters.
|
||||
*/
|
||||
private _writeLinePart(message: string): void {
|
||||
let trimmedMessage: string = message;
|
||||
|
||||
if (this.trimLeadingSpaces && this._atStartOfLine) {
|
||||
trimmedMessage = message.replace(/^ +/, '');
|
||||
}
|
||||
|
||||
if (trimmedMessage.length > 0) {
|
||||
if (this._atStartOfLine && this._indentText.length > 0) {
|
||||
this._write(this._indentText);
|
||||
}
|
||||
|
||||
this._write(trimmedMessage);
|
||||
if (this._currentLineIsBlank && /\S/.test(trimmedMessage)) {
|
||||
this._currentLineIsBlank = false;
|
||||
}
|
||||
|
||||
this._atStartOfLine = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _writeNewLine(): void {
|
||||
if (this.indentBlankLines && this._atStartOfLine && this._indentText.length > 0) {
|
||||
this._write(this._indentText);
|
||||
}
|
||||
|
||||
this._previousLineIsBlank = this._currentLineIsBlank;
|
||||
this._write('\n');
|
||||
this._currentLineIsBlank = true;
|
||||
this._atStartOfLine = true;
|
||||
}
|
||||
|
||||
private _write(str: string): void {
|
||||
this._previousChunk = this._latestChunk;
|
||||
this._latestChunk = str;
|
||||
this._builder.append(str);
|
||||
}
|
||||
|
||||
private _updateIndentText(): void {
|
||||
this._indentText = this._indentStack.join('');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
import { IndentedWriter } from '../IndentedWriter.js';
|
||||
|
||||
test('01 Demo from docs', () => {
|
||||
const indentedWriter: IndentedWriter = new IndentedWriter();
|
||||
indentedWriter.write('begin\n');
|
||||
indentedWriter.increaseIndent();
|
||||
indentedWriter.write('one\ntwo\n');
|
||||
indentedWriter.decreaseIndent();
|
||||
indentedWriter.increaseIndent();
|
||||
indentedWriter.decreaseIndent();
|
||||
indentedWriter.write('end');
|
||||
|
||||
expect(indentedWriter.toString()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('02 Indent something', () => {
|
||||
const indentedWriter: IndentedWriter = new IndentedWriter();
|
||||
indentedWriter.write('a');
|
||||
indentedWriter.write('b');
|
||||
indentedWriter.increaseIndent();
|
||||
indentedWriter.writeLine('c');
|
||||
indentedWriter.writeLine('d');
|
||||
indentedWriter.decreaseIndent();
|
||||
indentedWriter.writeLine('e');
|
||||
|
||||
indentedWriter.increaseIndent('>>> ');
|
||||
indentedWriter.writeLine();
|
||||
indentedWriter.writeLine();
|
||||
indentedWriter.writeLine('g');
|
||||
indentedWriter.decreaseIndent();
|
||||
|
||||
expect(indentedWriter.toString()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('03 Indent something with indentBlankLines=true', () => {
|
||||
const indentedWriter: IndentedWriter = new IndentedWriter();
|
||||
indentedWriter.indentBlankLines = true;
|
||||
|
||||
indentedWriter.write('a');
|
||||
indentedWriter.write('b');
|
||||
indentedWriter.increaseIndent();
|
||||
indentedWriter.writeLine('c');
|
||||
indentedWriter.writeLine('d');
|
||||
indentedWriter.decreaseIndent();
|
||||
indentedWriter.writeLine('e');
|
||||
|
||||
indentedWriter.increaseIndent('>>> ');
|
||||
indentedWriter.writeLine();
|
||||
indentedWriter.writeLine();
|
||||
indentedWriter.writeLine('g');
|
||||
indentedWriter.decreaseIndent();
|
||||
|
||||
expect(indentedWriter.toString()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('04 Two kinds of indents', () => {
|
||||
const indentedWriter: IndentedWriter = new IndentedWriter();
|
||||
|
||||
indentedWriter.writeLine('---');
|
||||
indentedWriter.indentScope(() => {
|
||||
indentedWriter.write('a\nb');
|
||||
indentedWriter.indentScope(() => {
|
||||
indentedWriter.write('c\nd\n');
|
||||
});
|
||||
indentedWriter.write('e\n');
|
||||
}, '> ');
|
||||
indentedWriter.writeLine('---');
|
||||
|
||||
expect(indentedWriter.toString()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('05 Edge cases for ensureNewLine()', () => {
|
||||
let indentedWriter: IndentedWriter = new IndentedWriter();
|
||||
indentedWriter.ensureNewLine();
|
||||
indentedWriter.write('line');
|
||||
expect(indentedWriter.toString()).toMatchSnapshot();
|
||||
|
||||
indentedWriter = new IndentedWriter();
|
||||
indentedWriter.write('previous');
|
||||
indentedWriter.ensureNewLine();
|
||||
indentedWriter.write('line');
|
||||
expect(indentedWriter.toString()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('06 Edge cases for ensureSkippedLine()', () => {
|
||||
let indentedWriter: IndentedWriter = new IndentedWriter();
|
||||
indentedWriter.ensureSkippedLine();
|
||||
indentedWriter.write('line');
|
||||
expect(indentedWriter.toString()).toMatchSnapshot();
|
||||
|
||||
indentedWriter = new IndentedWriter();
|
||||
indentedWriter.write('previous');
|
||||
indentedWriter.ensureSkippedLine();
|
||||
indentedWriter.write('line');
|
||||
indentedWriter.ensureSkippedLine();
|
||||
expect(indentedWriter.toString()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('06 trimLeadingSpaces=true', () => {
|
||||
const indentedWriter: IndentedWriter = new IndentedWriter();
|
||||
indentedWriter.trimLeadingSpaces = true;
|
||||
|
||||
// Example from doc comment
|
||||
indentedWriter.increaseIndent(' ');
|
||||
indentedWriter.write(' a\n b c\n');
|
||||
indentedWriter.decreaseIndent();
|
||||
indentedWriter.ensureSkippedLine();
|
||||
indentedWriter.increaseIndent('>>');
|
||||
indentedWriter.write(' ');
|
||||
indentedWriter.write(' ');
|
||||
indentedWriter.write(' a');
|
||||
indentedWriter.writeLine(' b');
|
||||
indentedWriter.writeLine('\ttab'); // does not get indented
|
||||
indentedWriter.writeLine('c ');
|
||||
expect(indentedWriter.toString()).toMatchSnapshot();
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`01 Demo from docs 1`] = `
|
||||
"begin
|
||||
one
|
||||
two
|
||||
end"
|
||||
`;
|
||||
|
||||
exports[`02 Indent something 1`] = `
|
||||
"abc
|
||||
d
|
||||
e
|
||||
|
||||
|
||||
>>> g
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`03 Indent something with indentBlankLines=true 1`] = `
|
||||
"abc
|
||||
d
|
||||
e
|
||||
>>>
|
||||
>>>
|
||||
>>> g
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`04 Two kinds of indents 1`] = `
|
||||
"---
|
||||
> a
|
||||
> bc
|
||||
> d
|
||||
> e
|
||||
---
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`05 Edge cases for ensureNewLine() 1`] = `"line"`;
|
||||
|
||||
exports[`05 Edge cases for ensureNewLine() 2`] = `
|
||||
"previous
|
||||
line"
|
||||
`;
|
||||
|
||||
exports[`06 Edge cases for ensureSkippedLine() 1`] = `"line"`;
|
||||
|
||||
exports[`06 Edge cases for ensureSkippedLine() 2`] = `
|
||||
"previous
|
||||
|
||||
line
|
||||
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`06 trimLeadingSpaces=true 1`] = `
|
||||
" a
|
||||
b c
|
||||
|
||||
>>a b
|
||||
>> tab
|
||||
>>c
|
||||
"
|
||||
`;
|
||||
44
packages/api-extractor/src/index.ts
Normal file
44
packages/api-extractor/src/index.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
/**
|
||||
* API Extractor helps with validation, documentation, and reviewing of the exported API for a TypeScript library.
|
||||
* The `@microsoft/api-extractor` package provides the command-line tool. It also exposes a developer API that you
|
||||
* can use to invoke API Extractor programmatically.
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export { ConsoleMessageId } from './api/ConsoleMessageId.js';
|
||||
|
||||
export { CompilerState, type ICompilerStateCreateOptions } from './api/CompilerState.js';
|
||||
|
||||
export { Extractor, type IExtractorInvokeOptions, ExtractorResult } from './api/Extractor.js';
|
||||
|
||||
export {
|
||||
type IExtractorConfigPrepareOptions,
|
||||
type IExtractorConfigLoadForFolderOptions,
|
||||
ExtractorConfig,
|
||||
} from './api/ExtractorConfig.js';
|
||||
|
||||
export { ExtractorLogLevel } from './api/ExtractorLogLevel.js';
|
||||
|
||||
export {
|
||||
ExtractorMessage,
|
||||
type IExtractorMessageProperties,
|
||||
ExtractorMessageCategory,
|
||||
} from './api/ExtractorMessage.js';
|
||||
|
||||
export { ExtractorMessageId } from './api/ExtractorMessageId.js';
|
||||
|
||||
export type {
|
||||
IConfigCompiler,
|
||||
IConfigApiReport,
|
||||
IConfigDocModel,
|
||||
IConfigDtsRollup,
|
||||
IConfigTsdocMetadata,
|
||||
IConfigMessageReportingRule,
|
||||
IConfigMessageReportingTable,
|
||||
IExtractorMessagesConfig,
|
||||
IConfigFile,
|
||||
} from './api/IConfigFile.js';
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"projectFolder": "<lookup>",
|
||||
|
||||
// ("mainEntryPointFilePath" is required)
|
||||
|
||||
"bundledPackages": [],
|
||||
|
||||
"newlineKind": "crlf",
|
||||
|
||||
"enumMemberOrder": "by-name",
|
||||
|
||||
"compiler": {
|
||||
"tsconfigFilePath": "<projectFolder>/tsconfig.json",
|
||||
"skipLibCheck": false
|
||||
},
|
||||
|
||||
"apiReport": {
|
||||
// ("enabled" is required)
|
||||
"reportFileName": "<unscopedPackageName>.api.md",
|
||||
"reportFolder": "<projectFolder>/etc/",
|
||||
"reportTempFolder": "<projectFolder>/temp/",
|
||||
"includeForgottenExports": false
|
||||
},
|
||||
|
||||
"docModel": {
|
||||
// ("enabled" is required)
|
||||
"apiJsonFilePath": "<projectFolder>/temp/<unscopedPackageName>.api.json",
|
||||
"includeForgottenExports": false
|
||||
},
|
||||
|
||||
"dtsRollup": {
|
||||
// ("enabled" is required)
|
||||
|
||||
"untrimmedFilePath": "<projectFolder>/dist/<unscopedPackageName>.d.ts",
|
||||
"alphaTrimmedFilePath": "",
|
||||
"betaTrimmedFilePath": "",
|
||||
"publicTrimmedFilePath": "",
|
||||
"omitTrimmingComments": false
|
||||
},
|
||||
|
||||
"tsdocMetadata": {
|
||||
"enabled": true,
|
||||
"tsdocMetadataFilePath": "<lookup>"
|
||||
},
|
||||
|
||||
"messages": {
|
||||
"compilerMessageReporting": {
|
||||
"default": {
|
||||
"logLevel": "warning"
|
||||
}
|
||||
},
|
||||
"extractorMessageReporting": {
|
||||
"default": {
|
||||
"logLevel": "warning"
|
||||
},
|
||||
"ae-forgotten-export": {
|
||||
"logLevel": "warning",
|
||||
"addToApiReportFile": true
|
||||
},
|
||||
"ae-incompatible-release-tags": {
|
||||
"logLevel": "warning",
|
||||
"addToApiReportFile": true
|
||||
},
|
||||
"ae-internal-missing-underscore": {
|
||||
"logLevel": "warning",
|
||||
"addToApiReportFile": true
|
||||
},
|
||||
"ae-internal-mixed-release-tag": {
|
||||
"logLevel": "warning",
|
||||
"addToApiReportFile": true
|
||||
},
|
||||
"ae-undocumented": {
|
||||
"logLevel": "none"
|
||||
},
|
||||
"ae-unresolved-inheritdoc-reference": {
|
||||
"logLevel": "warning",
|
||||
"addToApiReportFile": true
|
||||
},
|
||||
"ae-unresolved-inheritdoc-base": {
|
||||
"logLevel": "warning",
|
||||
"addToApiReportFile": true
|
||||
},
|
||||
"ae-wrong-input-file-type": {
|
||||
"logLevel": "error"
|
||||
}
|
||||
},
|
||||
"tsdocMessageReporting": {
|
||||
"default": {
|
||||
"logLevel": "warning"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"testMode": false
|
||||
}
|
||||
427
packages/api-extractor/src/schemas/api-extractor-template.json
Normal file
427
packages/api-extractor/src/schemas/api-extractor-template.json
Normal file
@@ -0,0 +1,427 @@
|
||||
/**
|
||||
* Config file for API Extractor. For more info, please visit: https://api-extractor.com
|
||||
*/
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
|
||||
|
||||
/**
|
||||
* Optionally specifies another JSON config file that this file extends from. This provides a way for
|
||||
* standard settings to be shared across multiple projects.
|
||||
*
|
||||
* If the path starts with "./" or "../", the path is resolved relative to the folder of the file that contains
|
||||
* the "extends" field. Otherwise, the first path segment is interpreted as an NPM package name, and will be
|
||||
* resolved using NodeJS require().
|
||||
*
|
||||
* SUPPORTED TOKENS: none
|
||||
* DEFAULT VALUE: ""
|
||||
*/
|
||||
// "extends": "./shared/api-extractor-base.json"
|
||||
// "extends": "my-package/include/api-extractor-base.json"
|
||||
|
||||
/**
|
||||
* Determines the "<projectFolder>" token that can be used with other config file settings. The project folder
|
||||
* typically contains the tsconfig.json and package.json config files, but the path is user-defined.
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting.
|
||||
*
|
||||
* The default value for "projectFolder" is the token "<lookup>", which means the folder is determined by traversing
|
||||
* parent folders, starting from the folder containing api-extractor.json, and stopping at the first folder
|
||||
* that contains a tsconfig.json file. If a tsconfig.json file cannot be found in this way, then an error
|
||||
* will be reported.
|
||||
*
|
||||
* SUPPORTED TOKENS: <lookup>
|
||||
* DEFAULT VALUE: "<lookup>"
|
||||
*/
|
||||
// "projectFolder": "..",
|
||||
|
||||
/**
|
||||
* (REQUIRED) Specifies the .d.ts file to be used as the starting point for analysis. API Extractor
|
||||
* analyzes the symbols exported by this module.
|
||||
*
|
||||
* The file extension must be ".d.ts" and not ".ts".
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
*/
|
||||
"mainEntryPointFilePath": "<projectFolder>/lib/index.d.ts",
|
||||
|
||||
/**
|
||||
* A list of NPM package names whose exports should be treated as part of this package.
|
||||
*
|
||||
* For example, suppose that Webpack is used to generate a distributed bundle for the project "library1",
|
||||
* and another NPM package "library2" is embedded in this bundle. Some types from library2 may become part
|
||||
* of the exported API for library1, but by default API Extractor would generate a .d.ts rollup that explicitly
|
||||
* imports library2. To avoid this, we can specify:
|
||||
*
|
||||
* "bundledPackages": [ "library2" ],
|
||||
*
|
||||
* This would direct API Extractor to embed those types directly in the .d.ts rollup, as if they had been
|
||||
* local files for library1.
|
||||
*/
|
||||
"bundledPackages": [],
|
||||
|
||||
/**
|
||||
* Specifies what type of newlines API Extractor should use when writing output files. By default, the output files
|
||||
* will be written with Windows-style newlines. To use POSIX-style newlines, specify "lf" instead.
|
||||
* To use the OS's default newline kind, specify "os".
|
||||
*
|
||||
* DEFAULT VALUE: "crlf"
|
||||
*/
|
||||
// "newlineKind": "crlf",
|
||||
|
||||
/**
|
||||
* Set to true when invoking API Extractor's test harness. When `testMode` is true, the `toolVersion` field in the
|
||||
* .api.json file is assigned an empty string to prevent spurious diffs in output files tracked for tests.
|
||||
*
|
||||
* DEFAULT VALUE: "false"
|
||||
*/
|
||||
// "testMode": false,
|
||||
|
||||
/**
|
||||
* Specifies how API Extractor sorts members of an enum when generating the .api.json file. By default, the output
|
||||
* files will be sorted alphabetically, which is "by-name". To keep the ordering in the source code, specify
|
||||
* "preserve".
|
||||
*
|
||||
* DEFAULT VALUE: "by-name"
|
||||
*/
|
||||
// "enumMemberOrder": "by-name",
|
||||
|
||||
/**
|
||||
* Determines how the TypeScript compiler engine will be invoked by API Extractor.
|
||||
*/
|
||||
"compiler": {
|
||||
/**
|
||||
* Specifies the path to the tsconfig.json file to be used by API Extractor when analyzing the project.
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* Note: This setting will be ignored if "overrideTsconfig" is used.
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: "<projectFolder>/tsconfig.json"
|
||||
*/
|
||||
// "tsconfigFilePath": "<projectFolder>/tsconfig.json",
|
||||
/**
|
||||
* Provides a compiler configuration that will be used instead of reading the tsconfig.json file from disk.
|
||||
* The object must conform to the TypeScript tsconfig schema:
|
||||
*
|
||||
* http://json.schemastore.org/tsconfig
|
||||
*
|
||||
* If omitted, then the tsconfig.json file will be read from the "projectFolder".
|
||||
*
|
||||
* DEFAULT VALUE: no overrideTsconfig section
|
||||
*/
|
||||
// "overrideTsconfig": {
|
||||
// . . .
|
||||
// }
|
||||
/**
|
||||
* This option causes the compiler to be invoked with the --skipLibCheck option. This option is not recommended
|
||||
* and may cause API Extractor to produce incomplete or incorrect declarations, but it may be required when
|
||||
* dependencies contain declarations that are incompatible with the TypeScript engine that API Extractor uses
|
||||
* for its analysis. Where possible, the underlying issue should be fixed rather than relying on skipLibCheck.
|
||||
*
|
||||
* DEFAULT VALUE: false
|
||||
*/
|
||||
// "skipLibCheck": true,
|
||||
},
|
||||
|
||||
/**
|
||||
* Configures how the API report file (*.api.md) will be generated.
|
||||
*/
|
||||
"apiReport": {
|
||||
/**
|
||||
* (REQUIRED) Whether to generate an API report.
|
||||
*/
|
||||
"enabled": true
|
||||
|
||||
/**
|
||||
* The filename for the API report files. It will be combined with "reportFolder" or "reportTempFolder" to produce
|
||||
* a full file path.
|
||||
*
|
||||
* The file extension should be ".api.md", and the string should not contain a path separator such as "\" or "/".
|
||||
*
|
||||
* SUPPORTED TOKENS: <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: "<unscopedPackageName>.api.md"
|
||||
*/
|
||||
// "reportFileName": "<unscopedPackageName>.api.md",
|
||||
|
||||
/**
|
||||
* Specifies the folder where the API report file is written. The file name portion is determined by
|
||||
* the "reportFileName" setting.
|
||||
*
|
||||
* The API report file is normally tracked by Git. Changes to it can be used to trigger a branch policy,
|
||||
* e.g. for an API review.
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: "<projectFolder>/temp/"
|
||||
*/
|
||||
// "reportFolder": "<projectFolder>/temp/",
|
||||
|
||||
/**
|
||||
* Specifies the folder where the temporary report file is written. The file name portion is determined by
|
||||
* the "reportFileName" setting.
|
||||
*
|
||||
* After the temporary file is written to disk, it is compared with the file in the "reportFolder".
|
||||
* If they are different, a production build will fail.
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: "<projectFolder>/temp/"
|
||||
*/
|
||||
// "reportTempFolder": "<projectFolder>/temp/",
|
||||
|
||||
/**
|
||||
* Whether "forgotten exports" should be included in the API report file. Forgotten exports are declarations
|
||||
* flagged with `ae-forgotten-export` warnings. See https://api-extractor.com/pages/messages/ae-forgotten-export/ to
|
||||
* learn more.
|
||||
*
|
||||
* DEFAULT VALUE: "false"
|
||||
*/
|
||||
// "includeForgottenExports": false
|
||||
},
|
||||
|
||||
/**
|
||||
* Configures how the doc model file (*.api.json) will be generated.
|
||||
*/
|
||||
"docModel": {
|
||||
/**
|
||||
* (REQUIRED) Whether to generate a doc model file.
|
||||
*/
|
||||
"enabled": true
|
||||
|
||||
/**
|
||||
* The output path for the doc model file. The file extension should be ".api.json".
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: "<projectFolder>/temp/<unscopedPackageName>.api.json"
|
||||
*/
|
||||
// "apiJsonFilePath": "<projectFolder>/temp/<unscopedPackageName>.api.json",
|
||||
|
||||
/**
|
||||
* Whether "forgotten exports" should be included in the doc model file. Forgotten exports are declarations
|
||||
* flagged with `ae-forgotten-export` warnings. See https://api-extractor.com/pages/messages/ae-forgotten-export/ to
|
||||
* learn more.
|
||||
*
|
||||
* DEFAULT VALUE: "false"
|
||||
*/
|
||||
// "includeForgottenExports": false,
|
||||
|
||||
/**
|
||||
* The base URL where the project's source code can be viewed on a website such as GitHub or
|
||||
* Azure DevOps. This URL path corresponds to the `<projectFolder>` path on disk.
|
||||
*
|
||||
* This URL is concatenated with the file paths serialized to the doc model to produce URL file paths to individual API items.
|
||||
* For example, if the `projectFolderUrl` is "https://github.com/microsoft/rushstack/tree/main/apps/api-extractor" and an API
|
||||
* item's file path is "api/ExtractorConfig.ts", the full URL file path would be
|
||||
* "https://github.com/microsoft/rushstack/tree/main/apps/api-extractor/api/ExtractorConfig.js".
|
||||
*
|
||||
* Can be omitted if you don't need source code links in your API documentation reference.
|
||||
*
|
||||
* SUPPORTED TOKENS: none
|
||||
* DEFAULT VALUE: ""
|
||||
*/
|
||||
// "projectFolderUrl": "http://github.com/path/to/your/projectFolder"
|
||||
},
|
||||
|
||||
/**
|
||||
* Configures how the .d.ts rollup file will be generated.
|
||||
*/
|
||||
"dtsRollup": {
|
||||
/**
|
||||
* (REQUIRED) Whether to generate the .d.ts rollup file.
|
||||
*/
|
||||
"enabled": true
|
||||
|
||||
/**
|
||||
* Specifies the output path for a .d.ts rollup file to be generated without any trimming.
|
||||
* This file will include all declarations that are exported by the main entry point.
|
||||
*
|
||||
* If the path is an empty string, then this file will not be written.
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: "<projectFolder>/dist/<unscopedPackageName>.d.ts"
|
||||
*/
|
||||
// "untrimmedFilePath": "<projectFolder>/dist/<unscopedPackageName>.d.ts",
|
||||
|
||||
/**
|
||||
* Specifies the output path for a .d.ts rollup file to be generated with trimming for an "alpha" release.
|
||||
* This file will include only declarations that are marked as "@public", "@beta", or "@alpha".
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: ""
|
||||
*/
|
||||
// "alphaTrimmedFilePath": "<projectFolder>/dist/<unscopedPackageName>-alpha.d.ts",
|
||||
|
||||
/**
|
||||
* Specifies the output path for a .d.ts rollup file to be generated with trimming for a "beta" release.
|
||||
* This file will include only declarations that are marked as "@public" or "@beta".
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: ""
|
||||
*/
|
||||
// "betaTrimmedFilePath": "<projectFolder>/dist/<unscopedPackageName>-beta.d.ts",
|
||||
|
||||
/**
|
||||
* Specifies the output path for a .d.ts rollup file to be generated with trimming for a "public" release.
|
||||
* This file will include only declarations that are marked as "@public".
|
||||
*
|
||||
* If the path is an empty string, then this file will not be written.
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: ""
|
||||
*/
|
||||
// "publicTrimmedFilePath": "<projectFolder>/dist/<unscopedPackageName>-public.d.ts",
|
||||
|
||||
/**
|
||||
* When a declaration is trimmed, by default it will be replaced by a code comment such as
|
||||
* "Excluded from this release type: exampleMember". Set "omitTrimmingComments" to true to remove the
|
||||
* declaration completely.
|
||||
*
|
||||
* DEFAULT VALUE: false
|
||||
*/
|
||||
// "omitTrimmingComments": true
|
||||
},
|
||||
|
||||
/**
|
||||
* Configures how the tsdoc-metadata.json file will be generated.
|
||||
*/
|
||||
"tsdocMetadata": {
|
||||
/**
|
||||
* Whether to generate the tsdoc-metadata.json file.
|
||||
*
|
||||
* DEFAULT VALUE: true
|
||||
*/
|
||||
// "enabled": true,
|
||||
/**
|
||||
* Specifies where the TSDoc metadata file should be written.
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* The default value is "<lookup>", which causes the path to be automatically inferred from the "tsdocMetadata",
|
||||
* "typings" or "main" fields of the project's package.json. If none of these fields are set, the lookup
|
||||
* falls back to "tsdoc-metadata.json" in the package folder.
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: "<lookup>"
|
||||
*/
|
||||
// "tsdocMetadataFilePath": "<projectFolder>/dist/tsdoc-metadata.json"
|
||||
},
|
||||
|
||||
/**
|
||||
* Configures how API Extractor reports error and warning messages produced during analysis.
|
||||
*
|
||||
* There are three sources of messages: compiler messages, API Extractor messages, and TSDoc messages.
|
||||
*/
|
||||
"messages": {
|
||||
/**
|
||||
* Configures handling of diagnostic messages reported by the TypeScript compiler engine while analyzing
|
||||
* the input .d.ts files.
|
||||
*
|
||||
* TypeScript message identifiers start with "TS" followed by an integer. For example: "TS2551"
|
||||
*
|
||||
* DEFAULT VALUE: A single "default" entry with logLevel=warning.
|
||||
*/
|
||||
"compilerMessageReporting": {
|
||||
/**
|
||||
* Configures the default routing for messages that don't match an explicit rule in this table.
|
||||
*/
|
||||
"default": {
|
||||
/**
|
||||
* Specifies whether the message should be written to the the tool's output log. Note that
|
||||
* the "addToApiReportFile" property may supersede this option.
|
||||
*
|
||||
* Possible values: "error", "warning", "none"
|
||||
*
|
||||
* Errors cause the build to fail and return a nonzero exit code. Warnings cause a production build fail
|
||||
* and return a nonzero exit code. For a non-production build (e.g. when "api-extractor run" includes
|
||||
* the "--local" option), the warning is displayed but the build will not fail.
|
||||
*
|
||||
* DEFAULT VALUE: "warning"
|
||||
*/
|
||||
"logLevel": "warning"
|
||||
|
||||
/**
|
||||
* When addToApiReportFile is true: If API Extractor is configured to write an API report file (.api.md),
|
||||
* then the message will be written inside that file; otherwise, the message is instead logged according to
|
||||
* the "logLevel" option.
|
||||
*
|
||||
* DEFAULT VALUE: false
|
||||
*/
|
||||
// "addToApiReportFile": false
|
||||
}
|
||||
|
||||
// "TS2551": {
|
||||
// "logLevel": "warning",
|
||||
// "addToApiReportFile": true
|
||||
// },
|
||||
//
|
||||
// . . .
|
||||
},
|
||||
|
||||
/**
|
||||
* Configures handling of messages reported by API Extractor during its analysis.
|
||||
*
|
||||
* API Extractor message identifiers start with "ae-". For example: "ae-extra-release-tag"
|
||||
*
|
||||
* DEFAULT VALUE: See api-extractor-defaults.json for the complete table of extractorMessageReporting mappings
|
||||
*/
|
||||
"extractorMessageReporting": {
|
||||
"default": {
|
||||
"logLevel": "warning"
|
||||
// "addToApiReportFile": false
|
||||
}
|
||||
|
||||
// "ae-extra-release-tag": {
|
||||
// "logLevel": "warning",
|
||||
// "addToApiReportFile": true
|
||||
// },
|
||||
//
|
||||
// . . .
|
||||
},
|
||||
|
||||
/**
|
||||
* Configures handling of messages reported by the TSDoc parser when analyzing code comments.
|
||||
*
|
||||
* TSDoc message identifiers start with "tsdoc-". For example: "tsdoc-link-tag-unescaped-text"
|
||||
*
|
||||
* DEFAULT VALUE: A single "default" entry with logLevel=warning.
|
||||
*/
|
||||
"tsdocMessageReporting": {
|
||||
"default": {
|
||||
"logLevel": "warning"
|
||||
// "addToApiReportFile": false
|
||||
}
|
||||
|
||||
// "tsdoc-link-tag-unescaped-text": {
|
||||
// "logLevel": "warning",
|
||||
// "addToApiReportFile": true
|
||||
// },
|
||||
//
|
||||
// . . .
|
||||
}
|
||||
}
|
||||
}
|
||||
229
packages/api-extractor/src/schemas/api-extractor.schema.json
Normal file
229
packages/api-extractor/src/schemas/api-extractor.schema.json
Normal file
@@ -0,0 +1,229 @@
|
||||
{
|
||||
"title": "API Extractor Configuration",
|
||||
"description": "Describes how the API Extractor tool will process a project.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"description": "Part of the JSON Schema standard, this optional keyword declares the URL of the schema that the file conforms to. Editors may download the schema and use it to perform syntax highlighting.",
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
"extends": {
|
||||
"description": "Optionally specifies another JSON config file that this file extends from. This provides a way for standard settings to be shared across multiple projects.",
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
"projectFolder": {
|
||||
"description": "Determines the \"<projectFolder>\" token that can be used with other config file settings. The project folder typically contains the tsconfig.json and package.json config files, but the path is user-defined. The path is resolved relative to the folder of the config file that contains the setting. The default value for \"projectFolder\" is the token \"<lookup>\", which means the folder is determined using the following heuristics:\n\nIf the config/rig.json system is used (as defined by @rushstack/rig-package), then the \"<lookup>\" value will be the package folder that referenced the rig.\n\nOtherwise, the \"<lookup>\" value is determined by traversing parent folders, starting from the folder containing api-extractor.json, and stopping at the first folder that contains a tsconfig.json file. If a tsconfig.json file cannot be found in this way, then an error will be reported.",
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
"mainEntryPointFilePath": {
|
||||
"description": "Specifies the .d.ts file to be used as the starting point for analysis. API Extractor analyzes the symbols exported by this module. The file extension must be \".d.ts\" and not \".ts\". The path is resolved relative to the folder of the config file that contains the setting; to change this, prepend a folder token such as \"<projectFolder>\".",
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
"bundledPackages": {
|
||||
"description": "A list of NPM package names whose exports should be treated as part of this package.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
|
||||
"enumMemberOrder": {
|
||||
"description": "Specifies how API Extractor sorts the members of an enum when generating the .api.json doc model. \n 'by-name': sort the items according to the enum member name \n 'preserve': keep the original order that items appear in the source code",
|
||||
"type": "string",
|
||||
"enum": ["by-name", "preserve"],
|
||||
"default": "by-name"
|
||||
},
|
||||
|
||||
"compiler": {
|
||||
"description": "Determines how the TypeScript compiler engine will be invoked by API Extractor.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tsconfigFilePath": {
|
||||
"description": "Specifies the path to the tsconfig.json file to be used by API Extractor when analyzing the project. The path is resolved relative to the folder of the config file that contains the setting; to change this, prepend a folder token such as \"<projectFolder>\". Note: This setting will be ignored if \"overrideTsconfig\" is used.",
|
||||
"type": "string"
|
||||
},
|
||||
"overrideTsconfig": {
|
||||
"description": "Provides a compiler configuration that will be used instead of reading the tsconfig.json file from disk. The object must conform to the TypeScript tsconfig schema: http://json.schemastore.org/tsconfig If omitted, then the tsconfig.json file will be read from the \"projectFolder\".",
|
||||
"type": "object"
|
||||
},
|
||||
"skipLibCheck": {
|
||||
"description": "This option causes the compiler to be invoked with the --skipLibCheck option. This option is not recommended and may cause API Extractor to produce incomplete or incorrect declarations, but it may be required when dependencies contain declarations that are incompatible with the TypeScript engine that API Extractor uses for its analysis. Where possible, the underlying issue should be fixed rather than relying on skipLibCheck.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
|
||||
"apiReport": {
|
||||
"description": "Configures how the API report file (*.api.md) will be generated.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"description": "Whether to generate an API report.",
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
"reportFileName": {
|
||||
"description": "The filename for the API report files. It will be combined with \"reportFolder\" or \"reportTempFolder\" to produce a full file path. The file extension should be \".api.md\", and the string should not contain a path separator such as \"\\\" or \"/\".",
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
"reportFolder": {
|
||||
"description": "Specifies the folder where the API report file is written. The file name portion is determined by the \"reportFileName\" setting. The API report file is normally tracked by Git. Changes to it can be used to trigger a branch policy, e.g. for an API review. The path is resolved relative to the folder of the config file that contains the setting; to change this, prepend a folder token such as \"<projectFolder>\".",
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
"reportTempFolder": {
|
||||
"description": "Specifies the folder where the temporary report file is written. The file name portion is determined by the \"reportFileName\" setting. After the temporary file is written to disk, it is compared with the file in the \"reportFolder\". If they are different, a production build will fail. The path is resolved relative to the folder of the config file that contains the setting; to change this, prepend a folder token such as \"<projectFolder>\".",
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
"includeForgottenExports": {
|
||||
"description": "Whether \"forgotten exports\" should be included in the API report file. Forgotten exports are declarations flagged with `ae-forgotten-export` warnings. See https://api-extractor.com/pages/messages/ae-forgotten-export/ to learn more.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["enabled"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
|
||||
"docModel": {
|
||||
"description": "Configures how the doc model file (*.api.json) will be generated.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"description": "Whether to generate doc model file.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"apiJsonFilePath": {
|
||||
"description": "The output path for the doc model file. The file extension should be \".api.json\". The path is resolved relative to the folder of the config file that contains the setting; to change this, prepend a folder token such as \"<projectFolder>\".",
|
||||
"type": "string"
|
||||
},
|
||||
"includeForgottenExports": {
|
||||
"description": "Whether \"forgotten exports\" should be included in the doc model file. Forgotten exports are declarations flagged with `ae-forgotten-export` warnings. See https://api-extractor.com/pages/messages/ae-forgotten-export/ to learn more.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"projectFolderUrl": {
|
||||
"description": "The base URL where the project's source code can be viewed on a website such as GitHub or Azure DevOps. This URL path corresponds to the `<projectFolder>` path on disk. This URL is concatenated with the file paths serialized to the doc model to produce URL file paths to individual API items. For example, if the `projectFolderUrl` is \"https://github.com/microsoft/rushstack/tree/main/apps/api-extractor\" and an API item's file path is \"api/ExtractorConfig.ts\", the full URL file path would be \"https://github.com/microsoft/rushstack/tree/main/apps/api-extractor/api/ExtractorConfig.js\". Can be omitted if you don't need source code links in your API documentation reference.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["enabled"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
|
||||
"dtsRollup": {
|
||||
"description": "Configures how the .d.ts rollup file will be generated.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"description": "Whether to generate the .d.ts rollup file.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"untrimmedFilePath": {
|
||||
"description": "Specifies the output path for a .d.ts rollup file to be generated without any trimming. This file will include all declarations that are exported by the main entry point. If the path is an empty string, then this file will not be written. The path is resolved relative to the folder of the config file that contains the setting; to change this, prepend a folder token such as \"<projectFolder>\".",
|
||||
"type": "string"
|
||||
},
|
||||
"alphaTrimmedFilePath": {
|
||||
"description": "Specifies the output path for a .d.ts rollup file to be generated with trimming for an \"alpha\" release. This file will include only declarations that are marked as \"@public\", \"@beta\", or \"@alpha\". The path is resolved relative to the folder of the config file that contains the setting; to change this, prepend a folder token such as \"<projectFolder>\".",
|
||||
"type": "string"
|
||||
},
|
||||
"betaTrimmedFilePath": {
|
||||
"description": "Specifies the output path for a .d.ts rollup file to be generated with trimming for a \"beta\" release. This file will include only declarations that are marked as \"@public\" or \"@beta\". The path is resolved relative to the folder of the config file that contains the setting; to change this, prepend a folder token such as \"<projectFolder>\".",
|
||||
"type": "string"
|
||||
},
|
||||
"publicTrimmedFilePath": {
|
||||
"description": "Specifies the output path for a .d.ts rollup file to be generated with trimming for a \"public\" release. This file will include only declarations that are marked as \"@public\". If the path is an empty string, then this file will not be written. The path is resolved relative to the folder of the config file that contains the setting; to change this, prepend a folder token such as \"<projectFolder>\".",
|
||||
"type": "string"
|
||||
},
|
||||
"omitTrimmingComments": {
|
||||
"description": "When a declaration is trimmed, by default it will be replaced by a code comment such as \"Excluded from this release type: exampleMember\". Set \"omitTrimmingComments\" to true to remove the declaration completely.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["enabled"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
|
||||
"tsdocMetadata": {
|
||||
"description": "Configures how the tsdoc-metadata.json file will be generated.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"description": "Whether to generate the tsdoc-metadata.json file.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"tsdocMetadataFilePath": {
|
||||
"description": "Specifies where the TSDoc metadata file should be written. The path is resolved relative to the folder of the config file that contains the setting; to change this, prepend a folder token such as \"<projectFolder>\". The default value is \"<lookup>\", which causes the path to be automatically inferred from the \"tsdocMetadata\", \"typings\" or \"main\" fields of the project's package.json. If none of these fields are set, the lookup falls back to \"tsdoc-metadata.json\" in the package folder.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
|
||||
"newlineKind": {
|
||||
"description": "Specifies what type of newlines API Extractor should use when writing output files. By default, the output files will be written with Windows-style newlines. To use POSIX-style newlines, specify \"lf\" instead. To use the OS's default newline kind, specify \"os\".",
|
||||
"type": "string",
|
||||
"enum": ["crlf", "lf", "os"],
|
||||
"default": "crlf"
|
||||
},
|
||||
|
||||
"messages": {
|
||||
"description": "Configures how API Extractor reports error and warning messages produced during analysis.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"compilerMessageReporting": {
|
||||
"description": "Configures handling of diagnostic messages generating the TypeScript compiler while analyzing the input .d.ts files.",
|
||||
"$ref": "#/definitions/extractorMessageReportingTable"
|
||||
},
|
||||
"extractorMessageReporting": {
|
||||
"description": "Configures handling of messages reported by API Extractor during its analysis.",
|
||||
"$ref": "#/definitions/extractorMessageReportingTable"
|
||||
},
|
||||
"tsdocMessageReporting": {
|
||||
"description": "Configures handling of messages reported by the TSDoc parser when analyzing code comments.",
|
||||
"$ref": "#/definitions/extractorMessageReportingTable"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
|
||||
"testMode": {
|
||||
"description": "Set to true invoking API Extractor's test harness. When \"testMode\" is true, the \"toolVersion\" field in the .api.json file is assigned an empty string to prevent spurious diffs in output files tracked for tests.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["mainEntryPointFilePath"],
|
||||
"additionalProperties": false,
|
||||
|
||||
"definitions": {
|
||||
"extractorMessageReportingTable": {
|
||||
"type": "object",
|
||||
"description": "Specifies a table of reporting rules for different message identifiers, and also the default rule used for identifiers that do not appear in the table. The key is a message identifier for the associated type of message, or \"default\" to specify the default policy. For example, the key might be \"TS2551\" (a compiler message), \"tsdoc-link-tag-unescaped-text\" (a TSDOc message), or \"ae-extra-release-tag\" (a message related to the API Extractor analysis).",
|
||||
"patternProperties": {
|
||||
".+": {
|
||||
"type": "object",
|
||||
"description": "Configures reporting for a given message identifier.",
|
||||
"properties": {
|
||||
"logLevel": {
|
||||
"type": "string",
|
||||
"description": "Specifies whether the message should be written to the the tool's output log. Note that the \"addToApiReportFile\" property may supersede this option.",
|
||||
"enum": ["error", "warning", "none"]
|
||||
},
|
||||
"addToApiReportFile": {
|
||||
"type": "boolean",
|
||||
"description": "If API Extractor is configured to write an API review file (.api.md), then the message will be written inside that file. If the API review file is NOT being written, then the message is instead logged according to the \"logLevel\" option."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": ["logLevel"]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
20
packages/api-extractor/src/start.ts
Normal file
20
packages/api-extractor/src/start.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
import * as os from 'node:os';
|
||||
import * as process from 'node:process';
|
||||
import colors from 'colors';
|
||||
import { Extractor } from './api/Extractor.js';
|
||||
import { ApiExtractorCommandLine } from './cli/ApiExtractorCommandLine.js';
|
||||
|
||||
console.log(
|
||||
os.EOL + colors.bold(`api-extractor ${Extractor.version} ` + colors.cyan(' - https://api-extractor.com/') + os.EOL),
|
||||
);
|
||||
|
||||
const parser: ApiExtractorCommandLine = new ApiExtractorCommandLine();
|
||||
|
||||
// eslint-disable-next-line promise/prefer-await-to-callbacks
|
||||
parser.execute().catch((error) => {
|
||||
console.error(colors.red(`An unexpected error occurred:`), error);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user