Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions apps/api-extractor/src/analyzer/AstImport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,18 @@ export class AstImport extends AstSyntheticEntity {
*/
public isTypeOnlyEverywhere: boolean;

/**
* Whether type errors on the import should be ignored, for example:
*
* ```ts
* /** \@ts-ignore *\/
* import type { X } from "y";
* ```
*
* This is set to true if the ignored form is used in *any* reference to this AstImport.
*/
public isTsIgnored: 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
Expand All @@ -125,6 +137,7 @@ export class AstImport extends AstSyntheticEntity {
this.importKind = options.importKind;
this.modulePath = options.modulePath;
this.exportName = options.exportName;
this.isTsIgnored = false;

// We start with this assumption, but it may get changed later if non-type-only import is encountered.
this.isTypeOnlyEverywhere = options.isTypeOnly;
Expand Down
43 changes: 34 additions & 9 deletions apps/api-extractor/src/analyzer/ExportAnalyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { TypeScriptHelpers } from './TypeScriptHelpers';
import { AstSymbol } from './AstSymbol';
import { AstImport, type IAstImportOptions, AstImportKind } from './AstImport';
import { AstModule, type IAstModuleExportInfo } from './AstModule';
import { TypeScriptInternals } from './TypeScriptInternals';
import { CommentDirectiveType, TypeScriptInternals } from './TypeScriptInternals';
import { SourceFileLocationFormatter } from './SourceFileLocationFormatter';
import type { IFetchAstSymbolOptions } from './AstSymbolTable';
import type { AstEntity } from './AstEntity';
Expand Down Expand Up @@ -460,7 +460,7 @@ export class ExportAnalyzer {
exportName = SyntaxHelpers.makeCamelCaseIdentifier(externalModulePath);
}

return this._fetchAstImport(undefined, {
return this._fetchAstImport(undefined, node, {
importKind: AstImportKind.ImportType,
exportName: exportName,
modulePath: externalModulePath,
Expand Down Expand Up @@ -587,7 +587,7 @@ export class ExportAnalyzer {
const externalModulePath: string | undefined = this._tryGetExternalModulePath(exportDeclaration);

if (externalModulePath !== undefined) {
return this._fetchAstImport(declarationSymbol, {
return this._fetchAstImport(declarationSymbol, declaration, {
importKind: AstImportKind.NamedImport,
modulePath: externalModulePath,
exportName: exportName,
Expand Down Expand Up @@ -653,7 +653,7 @@ export class ExportAnalyzer {

// Here importSymbol=undefined because {@inheritDoc} and such are not going to work correctly for
// a package or source file.
return this._fetchAstImport(undefined, {
return this._fetchAstImport(undefined, importDeclaration, {
importKind: AstImportKind.StarImport,
exportName: declarationSymbol.name,
modulePath: externalModulePath,
Expand Down Expand Up @@ -686,7 +686,7 @@ export class ExportAnalyzer {
const exportName: string = (importSpecifier.propertyName || importSpecifier.name).getText().trim();

if (externalModulePath !== undefined) {
return this._fetchAstImport(declarationSymbol, {
return this._fetchAstImport(declarationSymbol, declaration, {
importKind: AstImportKind.NamedImport,
modulePath: externalModulePath,
exportName: exportName,
Expand Down Expand Up @@ -720,7 +720,7 @@ export class ExportAnalyzer {
: ts.InternalSymbolName.Default;

if (externalModulePath !== undefined) {
return this._fetchAstImport(declarationSymbol, {
return this._fetchAstImport(declarationSymbol, declaration, {
importKind: AstImportKind.DefaultImport,
modulePath: externalModulePath,
exportName,
Expand Down Expand Up @@ -762,7 +762,7 @@ export class ExportAnalyzer {
declaration.moduleReference.expression
);

return this._fetchAstImport(declarationSymbol, {
return this._fetchAstImport(declarationSymbol, declaration, {
importKind: AstImportKind.EqualsImport,
modulePath: externalModuleName,
exportName: variableName,
Expand Down Expand Up @@ -874,7 +874,7 @@ export class ExportAnalyzer {
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, {
return this._fetchAstImport(astSymbol.followedSymbol, undefined, {
importKind: AstImportKind.NamedImport,
modulePath: starExportedModule.externalModulePath,
exportName: exportName,
Expand Down Expand Up @@ -965,7 +965,11 @@ export class ExportAnalyzer {
return specifierAstModule;
}

private _fetchAstImport(importSymbol: ts.Symbol | undefined, options: IAstImportOptions): AstImport {
private _fetchAstImport(
importSymbol: ts.Symbol | undefined,
importNode: ts.Node | undefined,
options: IAstImportOptions
): AstImport {
const key: string = AstImport.getKey(options);

let astImport: AstImport | undefined = this._astImportsByKey.get(key);
Expand All @@ -992,6 +996,27 @@ export class ExportAnalyzer {
}
}

if (importNode && !astImport.isTsIgnored) {
const sourceFile: ts.SourceFile = importNode.getSourceFile();
for (const commentDirective of TypeScriptInternals.getCommentDirectives(sourceFile)) {
if (commentDirective.type !== CommentDirectiveType.Ignore) continue;
/* Directive comments apply to the first line after them that isn't whitespace or a single line comment */
const trailingCommentsAndWhitespace: number =
sourceFile
.getText()
.slice(commentDirective.range.end)
.match(/^(\/\/.*|[\t\v\f\ufeff\p{Zs}]|\r?\n|[\r\u2028\u2029])*/u)?.[0].length ?? 0;
if (
sourceFile.getLineAndCharacterOfPosition(importNode.getStart()).line ===
sourceFile.getLineAndCharacterOfPosition(commentDirective.range.end + trailingCommentsAndWhitespace)
.line
) {
astImport.isTsIgnored = true;
break;
}
}
}

return astImport;
}

Expand Down
29 changes: 29 additions & 0 deletions apps/api-extractor/src/analyzer/TypeScriptInternals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ export interface IGlobalVariableAnalyzer {
hasGlobalName(name: string): boolean;
}

export interface ICommentDirective {
range: ts.TextRange;
type: CommentDirectiveType;
}

export enum CommentDirectiveType {
ExpectError,
Ignore
}

export class TypeScriptInternals {
public static getImmediateAliasedSymbol(symbol: ts.Symbol, typeChecker: ts.TypeChecker): ts.Symbol {
// Compiler internal:
Expand Down Expand Up @@ -153,4 +163,23 @@ export class TypeScriptInternals {
// Compiler internal: https://github.com/microsoft/TypeScript/blob/71286e3d49c10e0e99faac360a6bbd40f12db7b6/src/compiler/utilities.ts#L925
return (ts as any).isVarConst(node);
}

/**
* Retrieves typescript directive comments from a SourceFile.
*/
public static getCommentDirectives(sourceFile: ts.SourceFile): ICommentDirective[] {
const sourceFileText: string = sourceFile.getText();
return ((sourceFile as any).commentDirectives ?? []).map(
(directive: ICommentDirective): ICommentDirective => {
const commentText: string = sourceFileText.slice(directive.range.pos, directive.range.end);
return {
range: directive.range,
/* Get `type` ourselves in case Typescript changes the enum members. */
type: commentText.includes('@ts-expect-error')
? CommentDirectiveType.ExpectError
: CommentDirectiveType.Ignore
};
}
);
}
}
4 changes: 3 additions & 1 deletion apps/api-extractor/src/generators/DtsEmitHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ export class DtsEmitHelpers {
collectorEntity: CollectorEntity,
astImport: AstImport
): void {
const importPrefix: string = astImport.isTypeOnlyEverywhere ? 'import type' : 'import';
const importPrefix: string =
(astImport.isTsIgnored ? '/** @ts-ignore */\n' : '') +
(astImport.isTypeOnlyEverywhere ? 'import type' : 'import');

switch (astImport.importKind) {
case AstImportKind.DefaultImport:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,46 @@
"endIndex": 2
}
]
},
{
"kind": "TypeAlias",
"canonicalReference": "api-extractor-scenarios!MaybeImported:type",
"docComment": "/**\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "export type MaybeImported = "
},
{
"kind": "Content",
"text": "[0] extends [1 & "
},
{
"kind": "Reference",
"text": "Invalid",
"canonicalReference": "api-extractor-scenarios!~Invalid:type"
},
{
"kind": "Content",
"text": "] ? never : "
},
{
"kind": "Reference",
"text": "Invalid",
"canonicalReference": "api-extractor-scenarios!~Invalid:type"
},
{
"kind": "Content",
"text": ";"
}
],
"fileUrlPath": "src/importType/index.ts",
"releaseTag": "Public",
"name": "MaybeImported",
"typeTokenRange": {
"startIndex": 1,
"endIndex": 5
}
}
]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

```ts

/** @ts-ignore */
import { Invalid as Invalid_2 } from 'maybe-invalid-import';
import type { Lib1Class } from 'api-extractor-lib1-test';
import { Lib1Interface } from 'api-extractor-lib1-test';

Expand All @@ -19,6 +21,15 @@ export interface B extends Lib1Interface {
export interface C extends Lib1Interface {
}

// Warning: (ae-forgotten-export) The symbol "Invalid" needs to be exported by the entry point index.d.ts
//
// @public (undocumented)
export type MaybeImported = [0] extends [1 & Invalid] ? never : Invalid;

// Warnings were encountered during analysis:
//
// src/importType/index.ts:9:5 - (tsdoc-characters-after-block-tag) The token "@ts" looks like a TSDoc tag but contains an invalid character "-"; if it is not a tag, use a backslash to escape the "@"

// (No @packageDocumentation comment for this package)

```
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
/** @ts-ignore */
import { Invalid as Invalid_2 } from 'maybe-invalid-import';
import type { Lib1Class } from 'api-extractor-lib1-test';
import { Lib1Interface } from 'api-extractor-lib1-test';

Expand All @@ -13,4 +15,10 @@ export declare interface B extends Lib1Interface {
export declare interface C extends Lib1Interface {
}

/** @ts-ignore */
declare type Invalid = Invalid_2;

/** @public */
export declare type MaybeImported = [0] extends [1 & Invalid] ? never : Invalid;

export { }
9 changes: 9 additions & 0 deletions build-tests/api-extractor-scenarios/src/importType/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ import type { Lib1Class, Lib1Interface } from 'api-extractor-lib1-test';
// This should prevent Lib1Interface from being emitted as a type-only import, even though B uses it that way.
import { Lib1Interface as Renamed } from 'api-extractor-lib1-test';

/** @ts-ignore */

// The ignore still applies past single-line comments and whitespace.

type Invalid = import('maybe-invalid-import').Invalid;

/** @public */
export type MaybeImported = [0] extends [1 & Invalid] ? never : Invalid;

/** @public */
export interface A extends Lib1Class {}

Expand Down
6 changes: 6 additions & 0 deletions build-tests/api-extractor-scenarios/src/runScenarios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ export async function runAsync(runScriptOptions: IRunScriptOptions): Promise<voi
logLevel: 'warning',
addToApiReportFile: true
}
},
tsdocMessageReporting: {
'tsdoc-characters-after-block-tag': {
logLevel: 'warning',
addToApiReportFile: true
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@microsoft/api-extractor",
"comment": "Preserve `@ts-ignore` directives on imports when generating a DTS rollup.",
"type": "patch"
}
],
"packageName": "@microsoft/api-extractor"
}