Skip to content

Commit 9c9f3e3

Browse files
author
Andy
authored
importFixes: When one file redirects to another, consider both for global import specifiers (#25834)
* importFixes: When one file redirects to another, consider both for global import specifiers * Add test for #26044 * Avoid a symlinked package globally importing itself (fixes another case of #26044) * Compare to node_modules with getCanonicalFileName
1 parent f326b4b commit 9c9f3e3

11 files changed

+187
-63
lines changed

src/compiler/checker.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3888,14 +3888,15 @@ namespace ts {
38883888
const links = getSymbolLinks(symbol);
38893889
let specifier = links.specifierCache && links.specifierCache.get(contextFile.path);
38903890
if (!specifier) {
3891-
specifier = flatten(moduleSpecifiers.getModuleSpecifiers(
3891+
specifier = moduleSpecifiers.getModuleSpecifierForDeclarationFile(
38923892
symbol,
38933893
compilerOptions,
38943894
contextFile,
38953895
context.tracker.moduleResolverHost,
38963896
context.tracker.moduleResolverHost.getSourceFiles!(), // TODO: GH#18217
3897-
{ importModuleSpecifierPreference: "non-relative" }
3898-
))[0];
3897+
{ importModuleSpecifierPreference: "non-relative" },
3898+
host.redirectTargetsMap,
3899+
);
38993900
links.specifierCache = links.specifierCache || createMap();
39003901
links.specifierCache.set(contextFile.path, specifier);
39013902
}

src/compiler/moduleSpecifiers.ts

Lines changed: 70 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Used by importFixes to synthesize import module specifiers.
1+
// Used by importFixes, getEditsForFileRename, and declaration emit to synthesize import module specifiers.
22
/* @internal */
33
namespace ts.moduleSpecifiers {
44
export interface ModuleSpecifierPreferences {
@@ -14,13 +14,26 @@ namespace ts.moduleSpecifiers {
1414
host: ModuleSpecifierResolutionHost,
1515
files: ReadonlyArray<SourceFile>,
1616
preferences: ModuleSpecifierPreferences = {},
17+
redirectTargetsMap: RedirectTargetsMap,
1718
): string {
1819
const info = getInfo(compilerOptions, importingSourceFile, importingSourceFileName, host);
19-
const modulePaths = getAllModulePaths(files, toFileName, info.getCanonicalFileName, host);
20+
const modulePaths = getAllModulePaths(files, importingSourceFileName, toFileName, info.getCanonicalFileName, host, redirectTargetsMap);
2021
return firstDefined(modulePaths, moduleFileName => getGlobalModuleSpecifier(moduleFileName, info, host, compilerOptions)) ||
2122
first(getLocalModuleSpecifiers(toFileName, info, compilerOptions, preferences));
2223
}
2324

25+
export function getModuleSpecifierForDeclarationFile(
26+
moduleSymbol: Symbol,
27+
compilerOptions: CompilerOptions,
28+
importingSourceFile: SourceFile,
29+
host: ModuleSpecifierResolutionHost,
30+
files: ReadonlyArray<SourceFile>,
31+
preferences: ModuleSpecifierPreferences,
32+
redirectTargetsMap: RedirectTargetsMap,
33+
): string {
34+
return first(first(getModuleSpecifiers(moduleSymbol, compilerOptions, importingSourceFile, host, files, preferences, redirectTargetsMap)));
35+
}
36+
2437
// For each symlink/original for a module, returns a list of ways to import that file.
2538
export function getModuleSpecifiers(
2639
moduleSymbol: Symbol,
@@ -29,6 +42,7 @@ namespace ts.moduleSpecifiers {
2942
host: ModuleSpecifierResolutionHost,
3043
files: ReadonlyArray<SourceFile>,
3144
preferences: ModuleSpecifierPreferences,
45+
redirectTargetsMap: RedirectTargetsMap,
3246
): ReadonlyArray<ReadonlyArray<string>> {
3347
const ambient = tryGetModuleNameFromAmbientModule(moduleSymbol);
3448
if (ambient) return [[ambient]];
@@ -37,7 +51,8 @@ namespace ts.moduleSpecifiers {
3751
if (!files) {
3852
return Debug.fail("Files list must be present to resolve symlinks in specifier resolution");
3953
}
40-
const modulePaths = getAllModulePaths(files, getSourceFileOfNode(moduleSymbol.valueDeclaration).fileName, info.getCanonicalFileName, host);
54+
const moduleSourceFile = getSourceFileOfNode(moduleSymbol.valueDeclaration);
55+
const modulePaths = getAllModulePaths(files, importingSourceFile.path, moduleSourceFile.fileName, info.getCanonicalFileName, host, redirectTargetsMap);
4156

4257
const global = mapDefined(modulePaths, moduleFileName => getGlobalModuleSpecifier(moduleFileName, info, host, compilerOptions));
4358
return global.length ? global.map(g => [g]) : modulePaths.map(moduleFileName =>
@@ -142,64 +157,69 @@ namespace ts.moduleSpecifiers {
142157
return firstDefined(imports, ({ text }) => pathIsRelative(text) ? fileExtensionIs(text, Extension.Js) : undefined) || false;
143158
}
144159

145-
function discoverProbableSymlinks(files: ReadonlyArray<SourceFile>, getCanonicalFileName: (file: string) => string, host: ModuleSpecifierResolutionHost) {
160+
function stringsEqual(a: string, b: string, getCanonicalFileName: GetCanonicalFileName): boolean {
161+
return getCanonicalFileName(a) === getCanonicalFileName(b);
162+
}
163+
164+
// KLUDGE: Don't assume one 'node_modules' links to another. More likely a single directory inside the node_modules is the symlink.
165+
// ALso, don't assume that an `@foo` directory is linked. More likely the contents of that are linked.
166+
function isNodeModulesOrScopedPackageDirectory(s: string, getCanonicalFileName: GetCanonicalFileName): boolean {
167+
return getCanonicalFileName(s) === "node_modules" || startsWith(s, "@");
168+
}
169+
170+
function guessDirectorySymlink(a: string, b: string, cwd: string, getCanonicalFileName: GetCanonicalFileName): [string, string] {
171+
const aParts = getPathComponents(toPath(a, cwd, getCanonicalFileName));
172+
const bParts = getPathComponents(toPath(b, cwd, getCanonicalFileName));
173+
while (!isNodeModulesOrScopedPackageDirectory(aParts[aParts.length - 2], getCanonicalFileName) &&
174+
!isNodeModulesOrScopedPackageDirectory(bParts[bParts.length - 2], getCanonicalFileName) &&
175+
stringsEqual(aParts[aParts.length - 1], bParts[bParts.length - 1], getCanonicalFileName)) {
176+
aParts.pop();
177+
bParts.pop();
178+
}
179+
return [getPathFromPathComponents(aParts), getPathFromPathComponents(bParts)];
180+
}
181+
182+
function discoverProbableSymlinks(files: ReadonlyArray<SourceFile>, getCanonicalFileName: GetCanonicalFileName, cwd: string): ReadonlyMap<string> {
183+
const result = createMap<string>();
146184
const symlinks = mapDefined(files, sf =>
147185
sf.resolvedModules && firstDefinedIterator(sf.resolvedModules.values(), res =>
148186
res && res.originalPath && res.resolvedFileName !== res.originalPath ? [res.resolvedFileName, res.originalPath] : undefined));
149-
const result = createMap<string>();
150-
if (symlinks) {
151-
const currentDirectory = host.getCurrentDirectory ? host.getCurrentDirectory() : "";
152-
const compareStrings = (!host.useCaseSensitiveFileNames || host.useCaseSensitiveFileNames()) ? compareStringsCaseSensitive : compareStringsCaseInsensitive;
153-
for (const [resolvedPath, originalPath] of symlinks) {
154-
const resolvedParts = getPathComponents(toPath(resolvedPath, currentDirectory, getCanonicalFileName));
155-
const originalParts = getPathComponents(toPath(originalPath, currentDirectory, getCanonicalFileName));
156-
while (compareStrings(resolvedParts[resolvedParts.length - 1], originalParts[originalParts.length - 1]) === Comparison.EqualTo) {
157-
resolvedParts.pop();
158-
originalParts.pop();
159-
}
160-
result.set(getPathFromPathComponents(originalParts), getPathFromPathComponents(resolvedParts));
161-
}
187+
for (const [resolvedPath, originalPath] of symlinks) {
188+
const [commonResolved, commonOriginal] = guessDirectorySymlink(resolvedPath, originalPath, cwd, getCanonicalFileName);
189+
result.set(commonOriginal, commonResolved);
162190
}
163191
return result;
164192
}
165193

166-
function getAllModulePathsUsingIndirectSymlinks(files: ReadonlyArray<SourceFile>, target: string, getCanonicalFileName: (file: string) => string, host: ModuleSpecifierResolutionHost) {
167-
const links = discoverProbableSymlinks(files, getCanonicalFileName, host);
168-
const paths = arrayFrom(links.keys());
169-
let options: string[] | undefined;
170-
const compareStrings = (!host.useCaseSensitiveFileNames || host.useCaseSensitiveFileNames()) ? compareStringsCaseSensitive : compareStringsCaseInsensitive;
171-
for (const path of paths) {
172-
const resolved = links.get(path)!;
173-
if (compareStrings(target.slice(0, resolved.length + 1), resolved + "/") === Comparison.EqualTo) {
174-
const relative = getRelativePathFromDirectory(resolved, target, getCanonicalFileName);
175-
const option = resolvePath(path, relative);
176-
if (!host.fileExists || host.fileExists(option)) {
177-
if (!options) options = [];
178-
options.push(option);
179-
}
180-
}
181-
}
182-
if (options) {
183-
options.push(target); // Since these are speculative, we also include the original resolved name as a possibility
184-
return options;
185-
}
186-
return [target];
187-
}
188-
189194
/**
190195
* Looks for existing imports that use symlinks to this module.
191-
* Only if no symlink is available, the real path will be used.
196+
* Symlinks will be returned first so they are preferred over the real path.
192197
*/
193-
function getAllModulePaths(files: ReadonlyArray<SourceFile>, importedFileName: string, getCanonicalFileName: (file: string) => string, host: ModuleSpecifierResolutionHost): ReadonlyArray<string> {
194-
const symlinks = mapDefined(files, sf =>
195-
sf.resolvedModules && firstDefinedIterator(sf.resolvedModules.values(), res =>
196-
res && res.resolvedFileName === importedFileName ? res.originalPath : undefined));
198+
function getAllModulePaths(files: ReadonlyArray<SourceFile>, importingFileName: string, importedFileName: string, getCanonicalFileName: GetCanonicalFileName, host: ModuleSpecifierResolutionHost, redirectTargetsMap: RedirectTargetsMap): ReadonlyArray<string> {
199+
const redirects = redirectTargetsMap.get(importedFileName);
200+
const importedFileNames = redirects ? [...redirects, importedFileName] : [importedFileName];
197201
const cwd = host.getCurrentDirectory ? host.getCurrentDirectory() : "";
198-
const baseOptions = getAllModulePathsUsingIndirectSymlinks(files, getNormalizedAbsolutePath(importedFileName, cwd), getCanonicalFileName, host);
199-
if (symlinks.length === 0) {
200-
return baseOptions;
201-
}
202-
return deduplicate(concatenate(baseOptions, flatMap(symlinks, importedFileName => getAllModulePathsUsingIndirectSymlinks(files, getNormalizedAbsolutePath(importedFileName, cwd), getCanonicalFileName, host))));
202+
const targets = importedFileNames.map(f => getNormalizedAbsolutePath(f, cwd));
203+
const links = discoverProbableSymlinks(files, getCanonicalFileName, cwd);
204+
205+
const result: string[] = [];
206+
const compareStrings = (!host.useCaseSensitiveFileNames || host.useCaseSensitiveFileNames()) ? compareStringsCaseSensitive : compareStringsCaseInsensitive;
207+
links.forEach((resolved, path) => {
208+
if (startsWithDirectory(importingFileName, resolved, getCanonicalFileName)) {
209+
return; // Don't want to a package to globally import from itself
210+
}
211+
212+
const target = targets.find(t => compareStrings(t.slice(0, resolved.length + 1), resolved + "/") === Comparison.EqualTo);
213+
if (target === undefined) return;
214+
215+
const relative = getRelativePathFromDirectory(resolved, target, getCanonicalFileName);
216+
const option = resolvePath(path, relative);
217+
if (!host.fileExists || host.fileExists(option)) {
218+
result.push(option);
219+
}
220+
});
221+
result.push(...targets);
222+
return result;
203223
}
204224

205225
function getRelativePathNParents(relativePath: string): number {

src/compiler/program.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -634,7 +634,8 @@ namespace ts {
634634
const packageIdToSourceFile = createMap<SourceFile>();
635635
// Maps from a SourceFile's `.path` to the name of the package it was imported with.
636636
let sourceFileToPackageName = createMap<string>();
637-
let redirectTargetsSet = createMap<true>();
637+
// Key is a file name. Value is the (non-empty, or undefined) list of files that redirect to it.
638+
let redirectTargetsMap = createMultiMap<string>();
638639

639640
const filesByName = createMap<SourceFile | undefined>();
640641
let missingFilePaths: ReadonlyArray<Path> | undefined;
@@ -752,7 +753,7 @@ namespace ts {
752753
getSourceFileFromReference,
753754
getLibFileFromReference,
754755
sourceFileToPackageName,
755-
redirectTargetsSet,
756+
redirectTargetsMap,
756757
isEmittedFile,
757758
getConfigFileParsingDiagnostics,
758759
getResolvedModuleWithFailedLookupLocationsFromCache,
@@ -1073,7 +1074,7 @@ namespace ts {
10731074
fileChanged = false;
10741075
newSourceFile = oldSourceFile; // Use the redirect.
10751076
}
1076-
else if (oldProgram.redirectTargetsSet.has(oldSourceFile.path)) {
1077+
else if (oldProgram.redirectTargetsMap.has(oldSourceFile.path)) {
10771078
// If a redirected-to source file changes, the redirect may be broken.
10781079
if (newSourceFile !== oldSourceFile) {
10791080
return oldProgram.structureIsReused = StructureIsReused.Not;
@@ -1220,7 +1221,7 @@ namespace ts {
12201221
resolvedProjectReferences = oldProgram.getProjectReferences();
12211222

12221223
sourceFileToPackageName = oldProgram.sourceFileToPackageName;
1223-
redirectTargetsSet = oldProgram.redirectTargetsSet;
1224+
redirectTargetsMap = oldProgram.redirectTargetsMap;
12241225

12251226
return oldProgram.structureIsReused = StructureIsReused.Completely;
12261227
}
@@ -2079,7 +2080,7 @@ namespace ts {
20792080
// Some other SourceFile already exists with this package name and version.
20802081
// Instead of creating a duplicate, just redirect to the existing one.
20812082
const dupFile = createRedirectSourceFile(fileFromPackageId, file!, fileName, path); // TODO: GH#18217
2082-
redirectTargetsSet.set(fileFromPackageId.path, true);
2083+
redirectTargetsMap.add(fileFromPackageId.path, fileName);
20832084
filesByName.set(path, dupFile);
20842085
sourceFileToPackageName.set(path, packageId.name);
20852086
processingOtherFiles!.push(dupFile);

src/compiler/types.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2551,7 +2551,7 @@ namespace ts {
25512551
/**
25522552
* If two source files are for the same version of the same package, one will redirect to the other.
25532553
* (See `createRedirectSourceFile` in program.ts.)
2554-
* The redirect will have this set. The redirected-to source file will be in `redirectTargetsSet`.
2554+
* The redirect will have this set. The redirected-to source file will be in `redirectTargetsMap`.
25552555
*/
25562556
/* @internal */ redirectInfo?: RedirectInfo;
25572557

@@ -2798,7 +2798,7 @@ namespace ts {
27982798
/** Given a source file, get the name of the package it was imported from. */
27992799
/* @internal */ sourceFileToPackageName: Map<string>;
28002800
/** Set of all source files that some other source file redirects to. */
2801-
/* @internal */ redirectTargetsSet: Map<true>;
2801+
/* @internal */ redirectTargetsMap: MultiMap<string>;
28022802
/** Is the file emitted file */
28032803
/* @internal */ isEmittedFile(file: string): boolean;
28042804

@@ -2807,6 +2807,9 @@ namespace ts {
28072807
getProjectReferences(): (ResolvedProjectReference | undefined)[] | undefined;
28082808
}
28092809

2810+
/* @internal */
2811+
export type RedirectTargetsMap = ReadonlyMap<ReadonlyArray<string>>;
2812+
28102813
export interface ResolvedProjectReference {
28112814
commandLine: ParsedCommandLine;
28122815
sourceFile: SourceFile;
@@ -2884,6 +2887,8 @@ namespace ts {
28842887
getSourceFiles(): ReadonlyArray<SourceFile>;
28852888
getSourceFile(fileName: string): SourceFile | undefined;
28862889
getResolvedTypeReferenceDirectives(): ReadonlyMap<ResolvedTypeReferenceDirective>;
2890+
2891+
readonly redirectTargetsMap: RedirectTargetsMap;
28872892
}
28882893

28892894
export interface TypeChecker {

src/compiler/utilities.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7157,6 +7157,12 @@ namespace ts {
71577157
return path.slice(0, Math.max(rootLength, path.lastIndexOf(directorySeparator)));
71587158
}
71597159

7160+
export function startsWithDirectory(fileName: string, directoryName: string, getCanonicalFileName: GetCanonicalFileName): boolean {
7161+
const canonicalFileName = getCanonicalFileName(fileName);
7162+
const canonicalDirectoryName = getCanonicalFileName(directoryName);
7163+
return startsWith(canonicalFileName, canonicalDirectoryName + "/") || startsWith(canonicalFileName, canonicalDirectoryName + "\\");
7164+
}
7165+
71607166
export function isUrl(path: string) {
71617167
return getEncodedRootLength(path) < 0;
71627168
}

src/services/codefixes/importFixes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,7 @@ namespace ts.codefix {
275275
): ReadonlyArray<FixAddNewImport | FixUseImportType> {
276276
const isJs = isSourceFileJavaScript(sourceFile);
277277
const choicesForEachExportingModule = flatMap<SymbolExportInfo, ReadonlyArray<FixAddNewImport | FixUseImportType>>(moduleSymbols, ({ moduleSymbol, importKind, exportedSymbolIsTypeOnly }) => {
278-
const modulePathsGroups = moduleSpecifiers.getModuleSpecifiers(moduleSymbol, program.getCompilerOptions(), sourceFile, host, program.getSourceFiles(), preferences);
278+
const modulePathsGroups = moduleSpecifiers.getModuleSpecifiers(moduleSymbol, program.getCompilerOptions(), sourceFile, host, program.getSourceFiles(), preferences, program.redirectTargetsMap);
279279
return modulePathsGroups.map(group => group.map((moduleSpecifier): FixAddNewImport | FixUseImportType =>
280280
// `position` should only be undefined at a missing jsx namespace, in which case we shouldn't be looking for pure types.
281281
exportedSymbolIsTypeOnly && isJs ? { kind: ImportFixKind.ImportType, moduleSpecifier, position: Debug.assertDefined(position) } : { kind: ImportFixKind.AddNew, moduleSpecifier, importKind }));

src/services/documentHighlights.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ namespace ts.DocumentHighlights {
2828
const map = arrayToMultiMap(referenceEntries.map(FindAllReferences.toHighlightSpan), e => e.fileName, e => e.span);
2929
return arrayFrom(map.entries(), ([fileName, highlightSpans]) => {
3030
if (!sourceFilesSet.has(fileName)) {
31-
Debug.assert(program.redirectTargetsSet.has(fileName));
31+
Debug.assert(program.redirectTargetsMap.has(fileName));
3232
const redirectTarget = program.getSourceFile(fileName);
3333
const redirect = find(sourceFilesToSearch, f => !!f.redirectInfo && f.redirectInfo.redirectTarget === redirectTarget)!;
3434
fileName = redirect.fileName;

src/services/getEditsForFileRename.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ namespace ts {
156156

157157
// Need an update if the imported file moved, or the importing file moved and was using a relative path.
158158
return toImport !== undefined && (toImport.updated || (importingSourceFileMoved && pathIsRelative(importLiteral.text)))
159-
? moduleSpecifiers.getModuleSpecifier(program.getCompilerOptions(), sourceFile, newImportFromPath, toImport.newFileName, host, allFiles, preferences)
159+
? moduleSpecifiers.getModuleSpecifier(program.getCompilerOptions(), sourceFile, newImportFromPath, toImport.newFileName, host, allFiles, preferences, program.redirectTargetsMap)
160160
: undefined;
161161
});
162162
}

0 commit comments

Comments
 (0)