Skip to content

Commit 78ad529

Browse files
committed
fix: preserve ts-ignore directives on imports
1 parent 29fe6d1 commit 78ad529

File tree

9 files changed

+153
-10
lines changed

9 files changed

+153
-10
lines changed

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,18 @@ export class AstImport extends AstSyntheticEntity {
104104
*/
105105
public isTypeOnlyEverywhere: boolean;
106106

107+
/**
108+
* Whether type errors on the import should be ignored, for example:
109+
*
110+
* ```ts
111+
* /** \@ts-ignore *\/
112+
* import type { X } from "y";
113+
* ```
114+
*
115+
* This is set to true if the ignored form is used in *any* reference to this AstImport.
116+
*/
117+
public isTsIgnored: boolean;
118+
107119
/**
108120
* If this import statement refers to an API from an external package that is tracked by API Extractor
109121
* (according to `PackageMetadataManager.isAedocSupportedFor()`), then this property will return the
@@ -125,6 +137,7 @@ export class AstImport extends AstSyntheticEntity {
125137
this.importKind = options.importKind;
126138
this.modulePath = options.modulePath;
127139
this.exportName = options.exportName;
140+
this.isTsIgnored = false;
128141

129142
// We start with this assumption, but it may get changed later if non-type-only import is encountered.
130143
this.isTypeOnlyEverywhere = options.isTypeOnly;

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

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { TypeScriptHelpers } from './TypeScriptHelpers';
99
import { AstSymbol } from './AstSymbol';
1010
import { AstImport, type IAstImportOptions, AstImportKind } from './AstImport';
1111
import { AstModule, type IAstModuleExportInfo } from './AstModule';
12-
import { TypeScriptInternals } from './TypeScriptInternals';
12+
import { CommentDirectiveType, TypeScriptInternals } from './TypeScriptInternals';
1313
import { SourceFileLocationFormatter } from './SourceFileLocationFormatter';
1414
import type { IFetchAstSymbolOptions } from './AstSymbolTable';
1515
import type { AstEntity } from './AstEntity';
@@ -460,7 +460,7 @@ export class ExportAnalyzer {
460460
exportName = SyntaxHelpers.makeCamelCaseIdentifier(externalModulePath);
461461
}
462462

463-
return this._fetchAstImport(undefined, {
463+
return this._fetchAstImport(undefined, node, {
464464
importKind: AstImportKind.ImportType,
465465
exportName: exportName,
466466
modulePath: externalModulePath,
@@ -587,7 +587,7 @@ export class ExportAnalyzer {
587587
const externalModulePath: string | undefined = this._tryGetExternalModulePath(exportDeclaration);
588588

589589
if (externalModulePath !== undefined) {
590-
return this._fetchAstImport(declarationSymbol, {
590+
return this._fetchAstImport(declarationSymbol, declaration, {
591591
importKind: AstImportKind.NamedImport,
592592
modulePath: externalModulePath,
593593
exportName: exportName,
@@ -653,7 +653,7 @@ export class ExportAnalyzer {
653653

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

688688
if (externalModulePath !== undefined) {
689-
return this._fetchAstImport(declarationSymbol, {
689+
return this._fetchAstImport(declarationSymbol, declaration, {
690690
importKind: AstImportKind.NamedImport,
691691
modulePath: externalModulePath,
692692
exportName: exportName,
@@ -720,7 +720,7 @@ export class ExportAnalyzer {
720720
: ts.InternalSymbolName.Default;
721721

722722
if (externalModulePath !== undefined) {
723-
return this._fetchAstImport(declarationSymbol, {
723+
return this._fetchAstImport(declarationSymbol, declaration, {
724724
importKind: AstImportKind.DefaultImport,
725725
modulePath: externalModulePath,
726726
exportName,
@@ -762,7 +762,7 @@ export class ExportAnalyzer {
762762
declaration.moduleReference.expression
763763
);
764764

765-
return this._fetchAstImport(declarationSymbol, {
765+
return this._fetchAstImport(declarationSymbol, declaration, {
766766
importKind: AstImportKind.EqualsImport,
767767
modulePath: externalModuleName,
768768
exportName: variableName,
@@ -874,7 +874,7 @@ export class ExportAnalyzer {
874874
if (starExportedModule.externalModulePath !== undefined) {
875875
// This entity was obtained from an external module, so return an AstImport instead
876876
const astSymbol: AstSymbol = astEntity as AstSymbol;
877-
return this._fetchAstImport(astSymbol.followedSymbol, {
877+
return this._fetchAstImport(astSymbol.followedSymbol, undefined, {
878878
importKind: AstImportKind.NamedImport,
879879
modulePath: starExportedModule.externalModulePath,
880880
exportName: exportName,
@@ -965,7 +965,11 @@ export class ExportAnalyzer {
965965
return specifierAstModule;
966966
}
967967

968-
private _fetchAstImport(importSymbol: ts.Symbol | undefined, options: IAstImportOptions): AstImport {
968+
private _fetchAstImport(
969+
importSymbol: ts.Symbol | undefined,
970+
importNode: ts.Node | undefined,
971+
options: IAstImportOptions
972+
): AstImport {
969973
const key: string = AstImport.getKey(options);
970974

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

999+
if (importNode && !astImport.isTsIgnored) {
1000+
const sourceFile: ts.SourceFile = importNode.getSourceFile();
1001+
for (const commentDirective of TypeScriptInternals.getCommentDirectives(sourceFile)) {
1002+
if (commentDirective.type !== CommentDirectiveType.Ignore) continue;
1003+
/* Directive comments apply to the first line after them that isn't whitespace or a single line comment */
1004+
const trailingCommentsAndWhitespace: number =
1005+
sourceFile
1006+
.getText()
1007+
.slice(commentDirective.range.end)
1008+
.match(/^(\/\/.*|[\t\v\f\ufeff\p{Zs}]|\r?\n|[\r\u2028\u2029])*/u)?.[0].length ?? 0;
1009+
if (
1010+
sourceFile.getLineAndCharacterOfPosition(importNode.getStart()).line ===
1011+
sourceFile.getLineAndCharacterOfPosition(commentDirective.range.end + trailingCommentsAndWhitespace)
1012+
.line
1013+
) {
1014+
astImport.isTsIgnored = true;
1015+
break;
1016+
}
1017+
}
1018+
}
1019+
9951020
return astImport;
9961021
}
9971022

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,16 @@ export interface IGlobalVariableAnalyzer {
1414
hasGlobalName(name: string): boolean;
1515
}
1616

17+
export interface ICommentDirective {
18+
range: ts.TextRange;
19+
type: CommentDirectiveType;
20+
}
21+
22+
export enum CommentDirectiveType {
23+
ExpectError,
24+
Ignore
25+
}
26+
1727
export class TypeScriptInternals {
1828
public static getImmediateAliasedSymbol(symbol: ts.Symbol, typeChecker: ts.TypeChecker): ts.Symbol {
1929
// Compiler internal:
@@ -153,4 +163,23 @@ export class TypeScriptInternals {
153163
// Compiler internal: https://github.com/microsoft/TypeScript/blob/71286e3d49c10e0e99faac360a6bbd40f12db7b6/src/compiler/utilities.ts#L925
154164
return (ts as any).isVarConst(node);
155165
}
166+
167+
/**
168+
* Retrieves typescript directive comments from a SourceFile.
169+
*/
170+
public static getCommentDirectives(sourceFile: ts.SourceFile): ICommentDirective[] {
171+
const sourceFileText: string = sourceFile.getText();
172+
return ((sourceFile as any).commentDirectives ?? []).map(
173+
(directive: ICommentDirective): ICommentDirective => {
174+
const commentText: string = sourceFileText.slice(directive.range.pos, directive.range.end);
175+
return {
176+
range: directive.range,
177+
/* Get `type` ourselves in case Typescript changes the enum members. */
178+
type: commentText.includes('@ts-expect-error')
179+
? CommentDirectiveType.ExpectError
180+
: CommentDirectiveType.Ignore
181+
};
182+
}
183+
);
184+
}
156185
}

apps/api-extractor/src/generators/DtsEmitHelpers.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ export class DtsEmitHelpers {
2222
collectorEntity: CollectorEntity,
2323
astImport: AstImport
2424
): void {
25-
const importPrefix: string = astImport.isTypeOnlyEverywhere ? 'import type' : 'import';
25+
const importPrefix: string =
26+
(astImport.isTsIgnored ? '/** @ts-ignore */\n' : '') +
27+
(astImport.isTypeOnlyEverywhere ? 'import type' : 'import');
2628

2729
switch (astImport.importKind) {
2830
case AstImportKind.DefaultImport:

build-tests/api-extractor-scenarios/etc/importType/api-extractor-scenarios.api.json

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,46 @@
264264
"endIndex": 2
265265
}
266266
]
267+
},
268+
{
269+
"kind": "TypeAlias",
270+
"canonicalReference": "api-extractor-scenarios!MaybeImported:type",
271+
"docComment": "/**\n * @public\n */\n",
272+
"excerptTokens": [
273+
{
274+
"kind": "Content",
275+
"text": "export type MaybeImported = "
276+
},
277+
{
278+
"kind": "Content",
279+
"text": "[0] extends [1 & "
280+
},
281+
{
282+
"kind": "Reference",
283+
"text": "Invalid",
284+
"canonicalReference": "api-extractor-scenarios!~Invalid:type"
285+
},
286+
{
287+
"kind": "Content",
288+
"text": "] ? never : "
289+
},
290+
{
291+
"kind": "Reference",
292+
"text": "Invalid",
293+
"canonicalReference": "api-extractor-scenarios!~Invalid:type"
294+
},
295+
{
296+
"kind": "Content",
297+
"text": ";"
298+
}
299+
],
300+
"fileUrlPath": "src/importType/index.ts",
301+
"releaseTag": "Public",
302+
"name": "MaybeImported",
303+
"typeTokenRange": {
304+
"startIndex": 1,
305+
"endIndex": 5
306+
}
267307
}
268308
]
269309
}

build-tests/api-extractor-scenarios/etc/importType/api-extractor-scenarios.api.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
55
```ts
66

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

@@ -19,6 +21,11 @@ export interface B extends Lib1Interface {
1921
export interface C extends Lib1Interface {
2022
}
2123

24+
// Warning: (ae-forgotten-export) The symbol "Invalid" needs to be exported by the entry point index.d.ts
25+
//
26+
// @public (undocumented)
27+
export type MaybeImported = [0] extends [1 & Invalid] ? never : Invalid;
28+
2229
// (No @packageDocumentation comment for this package)
2330

2431
```

build-tests/api-extractor-scenarios/etc/importType/rollup.d.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
/** @ts-ignore */
2+
import { Invalid as Invalid_2 } from 'maybe-invalid-import';
13
import type { Lib1Class } from 'api-extractor-lib1-test';
24
import { Lib1Interface } from 'api-extractor-lib1-test';
35

@@ -13,4 +15,10 @@ export declare interface B extends Lib1Interface {
1315
export declare interface C extends Lib1Interface {
1416
}
1517

18+
/** @ts-ignore */
19+
declare type Invalid = Invalid_2;
20+
21+
/** @public */
22+
export declare type MaybeImported = [0] extends [1 & Invalid] ? never : Invalid;
23+
1624
export { }

build-tests/api-extractor-scenarios/src/importType/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@ import type { Lib1Class, Lib1Interface } from 'api-extractor-lib1-test';
66
// This should prevent Lib1Interface from being emitted as a type-only import, even though B uses it that way.
77
import { Lib1Interface as Renamed } from 'api-extractor-lib1-test';
88

9+
/** @ts-ignore */
10+
11+
// The ignore still applies past single-line comments and whitespace.
12+
13+
type Invalid = import('maybe-invalid-import').Invalid;
14+
15+
/** @public */
16+
export type MaybeImported = [0] extends [1 & Invalid] ? never : Invalid;
17+
918
/** @public */
1019
export interface A extends Lib1Class {}
1120

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@microsoft/api-extractor",
5+
"comment": "Preserve `@ts-ignore` directives on imports when generating a DTS rollup.",
6+
"type": "patch"
7+
}
8+
],
9+
"packageName": "@microsoft/api-extractor"
10+
}

0 commit comments

Comments
 (0)