diff --git a/apps/api-extractor/src/analyzer/AstImport.ts b/apps/api-extractor/src/analyzer/AstImport.ts index 38d4b1928a9..deada92cc90 100644 --- a/apps/api-extractor/src/analyzer/AstImport.ts +++ b/apps/api-extractor/src/analyzer/AstImport.ts @@ -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 @@ -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; diff --git a/apps/api-extractor/src/analyzer/ExportAnalyzer.ts b/apps/api-extractor/src/analyzer/ExportAnalyzer.ts index 0bc6236aa4d..829b17a1b8c 100644 --- a/apps/api-extractor/src/analyzer/ExportAnalyzer.ts +++ b/apps/api-extractor/src/analyzer/ExportAnalyzer.ts @@ -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'; @@ -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, @@ -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, @@ -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, @@ -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, @@ -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, @@ -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, @@ -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, @@ -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); @@ -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; } diff --git a/apps/api-extractor/src/analyzer/TypeScriptInternals.ts b/apps/api-extractor/src/analyzer/TypeScriptInternals.ts index 00d04aa07fe..f0e8a7f5a04 100644 --- a/apps/api-extractor/src/analyzer/TypeScriptInternals.ts +++ b/apps/api-extractor/src/analyzer/TypeScriptInternals.ts @@ -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: @@ -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 + }; + } + ); + } } diff --git a/apps/api-extractor/src/generators/DtsEmitHelpers.ts b/apps/api-extractor/src/generators/DtsEmitHelpers.ts index 6cbb23c71b0..9f30a7af27d 100644 --- a/apps/api-extractor/src/generators/DtsEmitHelpers.ts +++ b/apps/api-extractor/src/generators/DtsEmitHelpers.ts @@ -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: diff --git a/build-tests/api-extractor-scenarios/etc/importType/api-extractor-scenarios.api.json b/build-tests/api-extractor-scenarios/etc/importType/api-extractor-scenarios.api.json index 9b7a0ba9812..3dd9bea8b29 100644 --- a/build-tests/api-extractor-scenarios/etc/importType/api-extractor-scenarios.api.json +++ b/build-tests/api-extractor-scenarios/etc/importType/api-extractor-scenarios.api.json @@ -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 + } } ] } diff --git a/build-tests/api-extractor-scenarios/etc/importType/api-extractor-scenarios.api.md b/build-tests/api-extractor-scenarios/etc/importType/api-extractor-scenarios.api.md index fc1e13919dd..3d6965d7a77 100644 --- a/build-tests/api-extractor-scenarios/etc/importType/api-extractor-scenarios.api.md +++ b/build-tests/api-extractor-scenarios/etc/importType/api-extractor-scenarios.api.md @@ -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'; @@ -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) ``` diff --git a/build-tests/api-extractor-scenarios/etc/importType/rollup.d.ts b/build-tests/api-extractor-scenarios/etc/importType/rollup.d.ts index 79be312a695..5c1d0970656 100644 --- a/build-tests/api-extractor-scenarios/etc/importType/rollup.d.ts +++ b/build-tests/api-extractor-scenarios/etc/importType/rollup.d.ts @@ -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'; @@ -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 { } diff --git a/build-tests/api-extractor-scenarios/src/importType/index.ts b/build-tests/api-extractor-scenarios/src/importType/index.ts index e9f48f45d1e..67f47db7041 100644 --- a/build-tests/api-extractor-scenarios/src/importType/index.ts +++ b/build-tests/api-extractor-scenarios/src/importType/index.ts @@ -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 {} diff --git a/build-tests/api-extractor-scenarios/src/runScenarios.ts b/build-tests/api-extractor-scenarios/src/runScenarios.ts index 6282ce60443..97e2a51cac1 100644 --- a/build-tests/api-extractor-scenarios/src/runScenarios.ts +++ b/build-tests/api-extractor-scenarios/src/runScenarios.ts @@ -20,6 +20,12 @@ export async function runAsync(runScriptOptions: IRunScriptOptions): Promise