Skip to content

Commit 954d044

Browse files
authored
Avoid auto-importing from barrel re-exporting index files that are likely to make an import cycle (#47516)
* Avoid auto-importing from barrel re-exporting index files that are likely to make an import cycle * Finish fixing merge conflict
1 parent b456702 commit 954d044

File tree

6 files changed

+140
-36
lines changed

6 files changed

+140
-36
lines changed

src/harness/fourslashImpl.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -3146,11 +3146,12 @@ namespace FourSlash {
31463146
});
31473147
}
31483148

3149-
public verifyImportFixModuleSpecifiers(markerName: string, moduleSpecifiers: string[]) {
3149+
public verifyImportFixModuleSpecifiers(markerName: string, moduleSpecifiers: string[], preferences?: ts.UserPreferences) {
31503150
const marker = this.getMarkerByName(markerName);
31513151
const codeFixes = this.getCodeFixes(marker.fileName, ts.Diagnostics.Cannot_find_name_0.code, {
31523152
includeCompletionsForModuleExports: true,
3153-
includeCompletionsWithInsertText: true
3153+
includeCompletionsWithInsertText: true,
3154+
...preferences,
31543155
}, marker.position).filter(f => f.fixName === ts.codefix.importFixName);
31553156

31563157
const actualModuleSpecifiers = ts.mapDefined(codeFixes, fix => {

src/harness/fourslashInterfaceImpl.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -478,8 +478,8 @@ namespace FourSlashInterface {
478478
this.state.verifyImportFixAtPosition(expectedTextArray, errorCode, preferences);
479479
}
480480

481-
public importFixModuleSpecifiers(marker: string, moduleSpecifiers: string[]) {
482-
this.state.verifyImportFixModuleSpecifiers(marker, moduleSpecifiers);
481+
public importFixModuleSpecifiers(marker: string, moduleSpecifiers: string[], preferences?: ts.UserPreferences) {
482+
this.state.verifyImportFixModuleSpecifiers(marker, moduleSpecifiers, preferences);
483483
}
484484

485485
public navigationBar(json: any, options?: { checkSpans?: boolean }) {

src/services/codefixes/importFixes.ts

+69-31
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,9 @@ namespace ts.codefix {
8181
const symbolName = getNameForExportedSymbol(exportedSymbol, getEmitScriptTarget(compilerOptions));
8282
const checker = program.getTypeChecker();
8383
const symbol = checker.getMergedSymbol(skipAlias(exportedSymbol, checker));
84-
const exportInfos = getAllReExportingModules(sourceFile, symbol, moduleSymbol, symbolName, /*isJsxTagName*/ false, host, program, preferences, useAutoImportProvider);
84+
const exportInfo = getAllReExportingModules(sourceFile, symbol, moduleSymbol, symbolName, /*isJsxTagName*/ false, host, program, preferences, useAutoImportProvider);
8585
const useRequire = shouldUseRequire(sourceFile, program);
86-
const fix = getImportFixForSymbol(sourceFile, exportInfos, moduleSymbol, symbolName, program, /*position*/ undefined, !!isValidTypeOnlyUseSite, useRequire, host, preferences);
86+
const fix = getImportFixForSymbol(sourceFile, exportInfo, moduleSymbol, symbolName, program, /*position*/ undefined, !!isValidTypeOnlyUseSite, useRequire, host, preferences);
8787
if (fix) {
8888
addImport({ fixes: [fix], symbolName, errorIdentifierText: undefined });
8989
}
@@ -248,32 +248,35 @@ namespace ts.codefix {
248248
}
249249
type ImportFix = FixUseNamespaceImport | FixAddJsdocTypeImport | FixAddToExistingImport | FixAddNewImport | FixPromoteTypeOnlyImport;
250250
type ImportFixWithModuleSpecifier = FixUseNamespaceImport | FixAddJsdocTypeImport | FixAddToExistingImport | FixAddNewImport;
251-
interface FixUseNamespaceImport {
251+
252+
// Properties are be undefined if fix is derived from an existing import
253+
interface ImportFixBase {
254+
readonly isReExport?: boolean;
255+
readonly exportInfo?: SymbolExportInfo;
256+
readonly moduleSpecifier: string;
257+
}
258+
interface FixUseNamespaceImport extends ImportFixBase {
252259
readonly kind: ImportFixKind.UseNamespace;
253260
readonly namespacePrefix: string;
254261
readonly position: number;
255-
readonly moduleSpecifier: string;
256262
}
257-
interface FixAddJsdocTypeImport {
263+
interface FixAddJsdocTypeImport extends ImportFixBase {
258264
readonly kind: ImportFixKind.JsdocTypeImport;
259-
readonly moduleSpecifier: string;
260265
readonly position: number;
266+
readonly isReExport: boolean;
261267
readonly exportInfo: SymbolExportInfo;
262268
}
263-
interface FixAddToExistingImport {
269+
interface FixAddToExistingImport extends ImportFixBase {
264270
readonly kind: ImportFixKind.AddToExisting;
265271
readonly importClauseOrBindingPattern: ImportClause | ObjectBindingPattern;
266-
readonly moduleSpecifier: string;
267272
readonly importKind: ImportKind.Default | ImportKind.Named;
268273
readonly addAsTypeOnly: AddAsTypeOnly;
269274
}
270-
interface FixAddNewImport {
275+
interface FixAddNewImport extends ImportFixBase {
271276
readonly kind: ImportFixKind.AddNew;
272-
readonly moduleSpecifier: string;
273277
readonly importKind: ImportKind;
274278
readonly addAsTypeOnly: AddAsTypeOnly;
275279
readonly useRequire: boolean;
276-
readonly exportInfo?: SymbolExportInfo;
277280
}
278281
interface FixPromoteTypeOnlyImport {
279282
readonly kind: ImportFixKind.PromoteTypeOnly;
@@ -331,7 +334,7 @@ namespace ts.codefix {
331334
function getImportFixForSymbol(sourceFile: SourceFile, exportInfos: readonly SymbolExportInfo[], moduleSymbol: Symbol, symbolName: string, program: Program, position: number | undefined, isValidTypeOnlyUseSite: boolean, useRequire: boolean, host: LanguageServiceHost, preferences: UserPreferences) {
332335
Debug.assert(exportInfos.some(info => info.moduleSymbol === moduleSymbol || info.symbol.parent === moduleSymbol), "Some exportInfo should match the specified moduleSymbol");
333336
const packageJsonImportFilter = createPackageJsonImportFilter(sourceFile, preferences, host);
334-
return getBestFix(getImportFixes(exportInfos, symbolName, position, isValidTypeOnlyUseSite, useRequire, program, sourceFile, host, preferences), sourceFile, program, packageJsonImportFilter);
337+
return getBestFix(getImportFixes(exportInfos, symbolName, position, isValidTypeOnlyUseSite, useRequire, program, sourceFile, host, preferences), sourceFile, program, packageJsonImportFilter, host);
335338
}
336339

337340
function codeFixActionToCodeAction({ description, changes, commands }: CodeFixAction): CodeAction {
@@ -410,7 +413,7 @@ namespace ts.codefix {
410413
host,
411414
preferences,
412415
fromCacheOnly);
413-
const result = getBestFix(fixes, importingFile, program, packageJsonImportFilter || createPackageJsonImportFilter(importingFile, preferences, host));
416+
const result = getBestFix(fixes, importingFile, program, packageJsonImportFilter || createPackageJsonImportFilter(importingFile, preferences, host), host);
414417
return result && { ...result, computedWithoutCacheCount };
415418
}
416419

@@ -606,7 +609,7 @@ namespace ts.codefix {
606609
position: number | undefined,
607610
isValidTypeOnlyUseSite: boolean,
608611
useRequire: boolean,
609-
moduleSymbols: readonly SymbolExportInfo[],
612+
exportInfo: readonly SymbolExportInfo[],
610613
host: LanguageServiceHost,
611614
preferences: UserPreferences,
612615
fromCacheOnly?: boolean,
@@ -620,7 +623,7 @@ namespace ts.codefix {
620623
: (moduleSymbol: Symbol, checker: TypeChecker) => moduleSpecifiers.getModuleSpecifiersWithCacheInfo(moduleSymbol, checker, compilerOptions, sourceFile, moduleSpecifierResolutionHost, preferences);
621624

622625
let computedWithoutCacheCount = 0;
623-
const fixes = flatMap(moduleSymbols, exportInfo => {
626+
const fixes = flatMap(exportInfo, (exportInfo, i) => {
624627
const checker = getChecker(exportInfo.isFromPackageJson);
625628
const { computedWithoutCache, moduleSpecifiers } = getModuleSpecifiers(exportInfo.moduleSymbol, checker);
626629
const importedSymbolHasValueMeaning = !!(exportInfo.targetFlags & SymbolFlags.Value);
@@ -629,14 +632,15 @@ namespace ts.codefix {
629632
return moduleSpecifiers?.map((moduleSpecifier): FixAddNewImport | FixAddJsdocTypeImport =>
630633
// `position` should only be undefined at a missing jsx namespace, in which case we shouldn't be looking for pure types.
631634
!importedSymbolHasValueMeaning && isJs && position !== undefined
632-
? { kind: ImportFixKind.JsdocTypeImport, moduleSpecifier, position, exportInfo }
635+
? { kind: ImportFixKind.JsdocTypeImport, moduleSpecifier, position, exportInfo, isReExport: i > 0 }
633636
: {
634637
kind: ImportFixKind.AddNew,
635638
moduleSpecifier,
636639
importKind: getImportKind(sourceFile, exportInfo.exportKind, compilerOptions),
637640
useRequire,
638641
addAsTypeOnly,
639642
exportInfo,
643+
isReExport: i > 0,
640644
}
641645
);
642646
});
@@ -675,7 +679,7 @@ namespace ts.codefix {
675679
}
676680
}
677681

678-
interface FixesInfo { readonly fixes: readonly ImportFix[]; readonly symbolName: string; readonly errorIdentifierText: string | undefined; }
682+
interface FixesInfo { readonly fixes: readonly ImportFix[], readonly symbolName: string, readonly errorIdentifierText: string | undefined }
679683
function getFixesInfo(context: CodeFixContextBase, errorCode: number, pos: number, useAutoImportProvider: boolean): FixesInfo | undefined {
680684
const symbolToken = getTokenAtPosition(context.sourceFile, pos);
681685
let info;
@@ -695,39 +699,73 @@ namespace ts.codefix {
695699
}
696700

697701
const packageJsonImportFilter = createPackageJsonImportFilter(context.sourceFile, context.preferences, context.host);
698-
return info && { ...info, fixes: sortFixes(info.fixes, context.sourceFile, context.program, packageJsonImportFilter) };
702+
return info && { ...info, fixes: sortFixes(info.fixes, context.sourceFile, context.program, packageJsonImportFilter, context.host) };
699703
}
700704

701-
function sortFixes(fixes: readonly ImportFixWithModuleSpecifier[], sourceFile: SourceFile, program: Program, packageJsonImportFilter: PackageJsonImportFilter): readonly ImportFixWithModuleSpecifier[] {
702-
return sort(fixes, (a, b) => compareValues(a.kind, b.kind) || compareModuleSpecifiers(a, b, sourceFile, program, packageJsonImportFilter.allowsImportingSpecifier));
705+
function sortFixes(fixes: readonly ImportFixWithModuleSpecifier[], sourceFile: SourceFile, program: Program, packageJsonImportFilter: PackageJsonImportFilter, host: LanguageServiceHost): readonly ImportFixWithModuleSpecifier[] {
706+
const _toPath = (fileName: string) => toPath(fileName, host.getCurrentDirectory(), hostGetCanonicalFileName(host));
707+
return sort(fixes, (a, b) => compareValues(a.kind, b.kind) || compareModuleSpecifiers(a, b, sourceFile, program, packageJsonImportFilter.allowsImportingSpecifier, _toPath));
703708
}
704709

705-
function getBestFix<T extends ImportFixWithModuleSpecifier>(fixes: readonly T[], sourceFile: SourceFile, program: Program, packageJsonImportFilter: PackageJsonImportFilter): T | undefined {
710+
function getBestFix(fixes: readonly ImportFixWithModuleSpecifier[], sourceFile: SourceFile, program: Program, packageJsonImportFilter: PackageJsonImportFilter, host: LanguageServiceHost): ImportFixWithModuleSpecifier | undefined {
706711
if (!some(fixes)) return;
707712
// These will always be placed first if available, and are better than other kinds
708713
if (fixes[0].kind === ImportFixKind.UseNamespace || fixes[0].kind === ImportFixKind.AddToExisting) {
709714
return fixes[0];
710715
}
716+
711717
return fixes.reduce((best, fix) =>
712718
// Takes true branch of conditional if `fix` is better than `best`
713-
compareModuleSpecifiers(fix, best, sourceFile, program, packageJsonImportFilter.allowsImportingSpecifier) === Comparison.LessThan ? fix : best
719+
compareModuleSpecifiers(
720+
fix,
721+
best,
722+
sourceFile,
723+
program,
724+
packageJsonImportFilter.allowsImportingSpecifier,
725+
fileName => toPath(fileName, host.getCurrentDirectory(), hostGetCanonicalFileName(host)),
726+
) === Comparison.LessThan ? fix : best
714727
);
715728
}
716729

717730
/** @returns `Comparison.LessThan` if `a` is better than `b`. */
718-
function compareModuleSpecifiers(a: ImportFixWithModuleSpecifier, b: ImportFixWithModuleSpecifier, importingFile: SourceFile, program: Program, allowsImportingSpecifier: (specifier: string) => boolean): Comparison {
731+
function compareModuleSpecifiers(
732+
a: ImportFixWithModuleSpecifier,
733+
b: ImportFixWithModuleSpecifier,
734+
importingFile: SourceFile,
735+
program: Program,
736+
allowsImportingSpecifier: (specifier: string) => boolean,
737+
toPath: (fileName: string) => Path,
738+
): Comparison {
719739
if (a.kind !== ImportFixKind.UseNamespace && b.kind !== ImportFixKind.UseNamespace) {
720740
return compareBooleans(allowsImportingSpecifier(b.moduleSpecifier), allowsImportingSpecifier(a.moduleSpecifier))
721741
|| compareNodeCoreModuleSpecifiers(a.moduleSpecifier, b.moduleSpecifier, importingFile, program)
722-
|| compareBooleans(isOnlyDotsAndSlashes(a.moduleSpecifier), isOnlyDotsAndSlashes(b.moduleSpecifier))
742+
|| compareBooleans(
743+
isFixPossiblyReExportingImportingFile(a, importingFile, program.getCompilerOptions(), toPath),
744+
isFixPossiblyReExportingImportingFile(b, importingFile, program.getCompilerOptions(), toPath))
723745
|| compareNumberOfDirectorySeparators(a.moduleSpecifier, b.moduleSpecifier);
724746
}
725747
return Comparison.EqualTo;
726748
}
727749

728-
const notDotOrSlashPattern = /[^.\/]/;
729-
function isOnlyDotsAndSlashes(path: string) {
730-
return !notDotOrSlashPattern.test(path);
750+
// This is a simple heuristic to try to avoid creating an import cycle with a barrel re-export.
751+
// E.g., do not `import { Foo } from ".."` when you could `import { Foo } from "../Foo"`.
752+
// This can produce false positives or negatives if re-exports cross into sibling directories
753+
// (e.g. `export * from "../whatever"`) or are not named "index" (we don't even try to consider
754+
// this if we're in a resolution mode where you can't drop trailing "/index" from paths).
755+
function isFixPossiblyReExportingImportingFile(fix: ImportFixWithModuleSpecifier, importingFile: SourceFile, compilerOptions: CompilerOptions, toPath: (fileName: string) => Path): boolean {
756+
if (fix.isReExport &&
757+
fix.exportInfo?.moduleFileName &&
758+
getEmitModuleResolutionKind(compilerOptions) === ModuleResolutionKind.NodeJs &&
759+
isIndexFileName(fix.exportInfo.moduleFileName)
760+
) {
761+
const reExportDir = toPath(getDirectoryPath(fix.exportInfo.moduleFileName));
762+
return startsWith((importingFile.path), reExportDir);
763+
}
764+
return false;
765+
}
766+
767+
function isIndexFileName(fileName: string) {
768+
return getBaseFileName(fileName, [".js", ".jsx", ".d.ts", ".ts", ".tsx"], /*ignoreCase*/ true) === "index";
731769
}
732770

733771
function compareNodeCoreModuleSpecifiers(a: string, b: string, importingFile: SourceFile, program: Program): Comparison {
@@ -742,9 +780,9 @@ namespace ts.codefix {
742780
if (!umdSymbol) return undefined;
743781
const symbol = checker.getAliasedSymbol(umdSymbol);
744782
const symbolName = umdSymbol.name;
745-
const exportInfos: readonly SymbolExportInfo[] = [{ symbol: umdSymbol, moduleSymbol: symbol, moduleFileName: undefined, exportKind: ExportKind.UMD, targetFlags: symbol.flags, isFromPackageJson: false }];
783+
const exportInfo: readonly SymbolExportInfo[] = [{ symbol: umdSymbol, moduleSymbol: symbol, moduleFileName: undefined, exportKind: ExportKind.UMD, targetFlags: symbol.flags, isFromPackageJson: false }];
746784
const useRequire = shouldUseRequire(sourceFile, program);
747-
const fixes = getImportFixes(exportInfos, symbolName, isIdentifier(token) ? token.getStart(sourceFile) : undefined, /*isValidTypeOnlyUseSite*/ false, useRequire, program, sourceFile, host, preferences);
785+
const fixes = getImportFixes(exportInfo, symbolName, isIdentifier(token) ? token.getStart(sourceFile) : undefined, /*isValidTypeOnlyUseSite*/ false, useRequire, program, sourceFile, host, preferences);
748786
return { fixes, symbolName, errorIdentifierText: tryCast(token, isIdentifier)?.text };
749787
}
750788
function getUmdSymbol(token: Node, checker: TypeChecker): Symbol | undefined {
@@ -814,8 +852,8 @@ namespace ts.codefix {
814852

815853
const isValidTypeOnlyUseSite = isValidTypeOnlyAliasUseSite(symbolToken);
816854
const useRequire = shouldUseRequire(sourceFile, program);
817-
const exportInfos = getExportInfos(symbolName, isJSXTagName(symbolToken), getMeaningFromLocation(symbolToken), cancellationToken, sourceFile, program, useAutoImportProvider, host, preferences);
818-
const fixes = arrayFrom(flatMapIterator(exportInfos.entries(), ([_, exportInfos]) =>
855+
const exportInfo = getExportInfos(symbolName, isJSXTagName(symbolToken), getMeaningFromLocation(symbolToken), cancellationToken, sourceFile, program, useAutoImportProvider, host, preferences);
856+
const fixes = arrayFrom(flatMapIterator(exportInfo.entries(), ([_, exportInfos]) =>
819857
getImportFixes(exportInfos, symbolName, symbolToken.getStart(sourceFile), isValidTypeOnlyUseSite, useRequire, program, sourceFile, host, preferences)));
820858
return { fixes, symbolName, errorIdentifierText: symbolToken.text };
821859
}

tests/cases/fourslash/fourslash.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,7 @@ declare namespace FourSlashInterface {
364364
fileAfterApplyingRefactorAtMarker(markerName: string, expectedContent: string, refactorNameToApply: string, formattingOptions?: FormatCodeOptions): void;
365365
getAndApplyCodeFix(errorCode?: number, index?: number): void;
366366
importFixAtPosition(expectedTextArray: string[], errorCode?: number, options?: UserPreferences): void;
367-
importFixModuleSpecifiers(marker: string, moduleSpecifiers: string[]): void;
367+
importFixModuleSpecifiers(marker: string, moduleSpecifiers: string[], options?: UserPreferences): void;
368368

369369
navigationBar(json: any, options?: { checkSpans?: boolean }): void;
370370
navigationTree(json: any, options?: { checkSpans?: boolean }): void;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/// <reference path="fourslash.ts" />
2+
3+
// @module: commonjs
4+
// @baseUrl: /
5+
6+
// @Filename: /proj/foo/a.ts
7+
//// export const A = 0;
8+
9+
// @Filename: /proj/foo/b.ts
10+
//// export {};
11+
//// A/*sibling*/
12+
13+
// @Filename: /proj/foo/index.ts
14+
//// export * from "./a";
15+
//// export * from "./b";
16+
17+
// @Filename: /proj/index.ts
18+
//// export * from "./foo";
19+
//// export * from "./src";
20+
21+
// @Filename: /proj/src/a.ts
22+
//// export {};
23+
//// A/*parent*/
24+
25+
// @Filename: /proj/src/utils.ts
26+
//// export function util() { return "util"; }
27+
//// export { A } from "../foo/a";
28+
29+
// @Filename: /proj/src/index.ts
30+
//// export * from "./a";
31+
32+
verify.importFixModuleSpecifiers("sibling", ["proj/foo/a", "proj/src/utils", "proj", "proj/foo"], { importModuleSpecifierPreference: "non-relative" });
33+
verify.importFixModuleSpecifiers("parent", ["proj/foo", "proj/foo/a", "proj/src/utils", "proj"], { importModuleSpecifierPreference: "non-relative" });
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/// <reference path="fourslash.ts" />
2+
3+
// @module: commonjs
4+
5+
// @Filename: /foo/a.ts
6+
//// export const A = 0;
7+
8+
// @Filename: /foo/b.ts
9+
//// export {};
10+
//// A/*sibling*/
11+
12+
// @Filename: /foo/index.ts
13+
//// export * from "./a";
14+
//// export * from "./b";
15+
16+
// @Filename: /index.ts
17+
//// export * from "./foo";
18+
//// export * from "./src";
19+
20+
// @Filename: /src/a.ts
21+
//// export {};
22+
//// A/*parent*/
23+
24+
// @Filename: /src/index.ts
25+
//// export * from "./a";
26+
27+
verify.importFixModuleSpecifiers("sibling", ["./a", "./index", "../index"], { importModuleSpecifierEnding: "index" });
28+
// Here, "../foo/a" and "../foo/index" actually have the same sorting score,
29+
// so the original program order is preserved, which seems coincidentally probably good?
30+
// In other words, a re-export is preferable only if it saves on directory separators
31+
// and isn't in an ancestor directory of the importing file.
32+
verify.importFixModuleSpecifiers("parent", ["../foo/a", "../foo/index", "../index"], { importModuleSpecifierEnding: "index" });

0 commit comments

Comments
 (0)