Skip to content

Commit 31bf80c

Browse files
authored
Fix path completions missing extensions for exports wildcards (#57312)
1 parent 1ca93fe commit 31bf80c

File tree

5 files changed

+120
-34
lines changed

5 files changed

+120
-34
lines changed

src/compiler/moduleSpecifiers.ts

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -120,29 +120,31 @@ import {
120120

121121
// Used by importFixes, getEditsForFileRename, and declaration emit to synthesize import module specifiers.
122122

123-
const enum RelativePreference {
123+
/** @internal */
124+
export const enum RelativePreference {
124125
Relative,
125126
NonRelative,
126127
Shortest,
127128
ExternalNonRelative,
128129
}
129130

130-
// Processed preferences
131-
interface Preferences {
131+
/** @internal */
132+
export interface ModuleSpecifierPreferences {
132133
readonly relativePreference: RelativePreference;
133134
/**
134135
* @param syntaxImpliedNodeFormat Used when the import syntax implies ESM or CJS irrespective of the mode of the file.
135136
*/
136137
getAllowedEndingsInPreferredOrder(syntaxImpliedNodeFormat?: SourceFile["impliedNodeFormat"]): ModuleSpecifierEnding[];
137138
}
138139

139-
function getPreferences(
140+
/** @internal */
141+
export function getModuleSpecifierPreferences(
140142
{ importModuleSpecifierPreference, importModuleSpecifierEnding }: UserPreferences,
141143
compilerOptions: CompilerOptions,
142144
importingSourceFile: SourceFile,
143145
oldImportSpecifier?: string,
144-
): Preferences {
145-
const preferredEnding = getPreferredEnding();
146+
): ModuleSpecifierPreferences {
147+
const filePreferredEnding = getPreferredEnding();
146148
return {
147149
relativePreference: oldImportSpecifier !== undefined ? (isExternalModuleNameRelative(oldImportSpecifier) ?
148150
RelativePreference.Relative :
@@ -152,6 +154,7 @@ function getPreferences(
152154
importModuleSpecifierPreference === "project-relative" ? RelativePreference.ExternalNonRelative :
153155
RelativePreference.Shortest,
154156
getAllowedEndingsInPreferredOrder: syntaxImpliedNodeFormat => {
157+
const preferredEnding = syntaxImpliedNodeFormat !== importingSourceFile.impliedNodeFormat ? getPreferredEnding(syntaxImpliedNodeFormat) : filePreferredEnding;
155158
if ((syntaxImpliedNodeFormat ?? importingSourceFile.impliedNodeFormat) === ModuleKind.ESNext) {
156159
if (shouldAllowImportingTsExtension(compilerOptions, importingSourceFile.fileName)) {
157160
return [ModuleSpecifierEnding.TsExtension, ModuleSpecifierEnding.JsExtension];
@@ -185,14 +188,14 @@ function getPreferences(
185188
},
186189
};
187190

188-
function getPreferredEnding(): ModuleSpecifierEnding {
191+
function getPreferredEnding(resolutionMode?: ResolutionMode): ModuleSpecifierEnding {
189192
if (oldImportSpecifier !== undefined) {
190193
if (hasJSFileExtension(oldImportSpecifier)) return ModuleSpecifierEnding.JsExtension;
191194
if (endsWith(oldImportSpecifier, "/index")) return ModuleSpecifierEnding.Index;
192195
}
193196
return getModuleSpecifierEndingPreference(
194197
importModuleSpecifierEnding,
195-
importingSourceFile.impliedNodeFormat,
198+
resolutionMode ?? importingSourceFile.impliedNodeFormat,
196199
compilerOptions,
197200
importingSourceFile,
198201
);
@@ -213,7 +216,7 @@ export function updateModuleSpecifier(
213216
oldImportSpecifier: string,
214217
options: ModuleSpecifierOptions = {},
215218
): string | undefined {
216-
const res = getModuleSpecifierWorker(compilerOptions, importingSourceFile, importingSourceFileName, toFileName, host, getPreferences({}, compilerOptions, importingSourceFile, oldImportSpecifier), {}, options);
219+
const res = getModuleSpecifierWorker(compilerOptions, importingSourceFile, importingSourceFileName, toFileName, host, getModuleSpecifierPreferences({}, compilerOptions, importingSourceFile, oldImportSpecifier), {}, options);
217220
if (res === oldImportSpecifier) return undefined;
218221
return res;
219222
}
@@ -233,7 +236,7 @@ export function getModuleSpecifier(
233236
host: ModuleSpecifierResolutionHost,
234237
options: ModuleSpecifierOptions = {},
235238
): string {
236-
return getModuleSpecifierWorker(compilerOptions, importingSourceFile, importingSourceFileName, toFileName, host, getPreferences({}, compilerOptions, importingSourceFile), {}, options);
239+
return getModuleSpecifierWorker(compilerOptions, importingSourceFile, importingSourceFileName, toFileName, host, getModuleSpecifierPreferences({}, compilerOptions, importingSourceFile), {}, options);
237240
}
238241

239242
/** @internal */
@@ -256,7 +259,7 @@ function getModuleSpecifierWorker(
256259
importingSourceFileName: string,
257260
toFileName: string,
258261
host: ModuleSpecifierResolutionHost,
259-
preferences: Preferences,
262+
preferences: ModuleSpecifierPreferences,
260263
userPreferences: UserPreferences,
261264
options: ModuleSpecifierOptions = {},
262265
): string {
@@ -377,7 +380,7 @@ function computeModuleSpecifiers(
377380
forAutoImport: boolean,
378381
): readonly string[] {
379382
const info = getInfo(importingSourceFile.fileName, host);
380-
const preferences = getPreferences(userPreferences, compilerOptions, importingSourceFile);
383+
const preferences = getModuleSpecifierPreferences(userPreferences, compilerOptions, importingSourceFile);
381384
const existingSpecifier = forEach(modulePaths, modulePath =>
382385
forEach(
383386
host.getFileIncludeReasons().get(toPath(modulePath.path, host.getCurrentDirectory(), info.getCanonicalFileName)),
@@ -488,9 +491,9 @@ function getInfo(importingSourceFileName: string, host: ModuleSpecifierResolutio
488491
};
489492
}
490493

491-
function getLocalModuleSpecifier(moduleFileName: string, info: Info, compilerOptions: CompilerOptions, host: ModuleSpecifierResolutionHost, importMode: ResolutionMode, preferences: Preferences): string;
492-
function getLocalModuleSpecifier(moduleFileName: string, info: Info, compilerOptions: CompilerOptions, host: ModuleSpecifierResolutionHost, importMode: ResolutionMode, preferences: Preferences, pathsOnly?: boolean): string | undefined;
493-
function getLocalModuleSpecifier(moduleFileName: string, info: Info, compilerOptions: CompilerOptions, host: ModuleSpecifierResolutionHost, importMode: ResolutionMode, { getAllowedEndingsInPreferredOrder: getAllowedEndingsInPrefererredOrder, relativePreference }: Preferences, pathsOnly?: boolean): string | undefined {
494+
function getLocalModuleSpecifier(moduleFileName: string, info: Info, compilerOptions: CompilerOptions, host: ModuleSpecifierResolutionHost, importMode: ResolutionMode, preferences: ModuleSpecifierPreferences): string;
495+
function getLocalModuleSpecifier(moduleFileName: string, info: Info, compilerOptions: CompilerOptions, host: ModuleSpecifierResolutionHost, importMode: ResolutionMode, preferences: ModuleSpecifierPreferences, pathsOnly?: boolean): string | undefined;
496+
function getLocalModuleSpecifier(moduleFileName: string, info: Info, compilerOptions: CompilerOptions, host: ModuleSpecifierResolutionHost, importMode: ResolutionMode, { getAllowedEndingsInPreferredOrder: getAllowedEndingsInPrefererredOrder, relativePreference }: ModuleSpecifierPreferences, pathsOnly?: boolean): string | undefined {
494497
const { baseUrl, paths, rootDirs } = compilerOptions;
495498
if (pathsOnly && !paths) {
496499
return undefined;
@@ -1015,7 +1018,7 @@ function tryGetModuleNameAsNodeModule({ path, isRedirect }: ModulePath, { getCan
10151018

10161019
// Simplify the full file path to something that can be resolved by Node.
10171020

1018-
const preferences = getPreferences(userPreferences, options, importingSourceFile);
1021+
const preferences = getModuleSpecifierPreferences(userPreferences, options, importingSourceFile);
10191022
const allowedEndings = preferences.getAllowedEndingsInPreferredOrder();
10201023
let moduleSpecifier = path;
10211024
let isPackageRootPath = false;

src/compiler/utilities.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ import {
187187
getLineAndCharacterOfPosition,
188188
getLinesBetweenPositions,
189189
getLineStarts,
190+
getModeForUsageLocation,
190191
getNameOfDeclaration,
191192
getNormalizedAbsolutePath,
192193
getNormalizedPathComponents,
@@ -9590,7 +9591,8 @@ export function usesExtensionsOnImports({ imports }: SourceFile, hasExtension: (
95909591
/** @internal */
95919592
export function getModuleSpecifierEndingPreference(preference: UserPreferences["importModuleSpecifierEnding"], resolutionMode: ResolutionMode, compilerOptions: CompilerOptions, sourceFile: SourceFile): ModuleSpecifierEnding {
95929593
const moduleResolution = getEmitModuleResolutionKind(compilerOptions);
9593-
if (preference === "js" || resolutionMode === ModuleKind.ESNext && ModuleResolutionKind.Node16 <= moduleResolution && moduleResolution <= ModuleResolutionKind.NodeNext) {
9594+
const moduleResolutionIsNodeNext = ModuleResolutionKind.Node16 <= moduleResolution && moduleResolution <= ModuleResolutionKind.NodeNext;
9595+
if (preference === "js" || resolutionMode === ModuleKind.ESNext && moduleResolutionIsNodeNext) {
95949596
// Extensions are explicitly requested or required. Now choose between .js and .ts.
95959597
if (!shouldAllowImportingTsExtension(compilerOptions)) {
95969598
return ModuleSpecifierEnding.JsExtension;
@@ -9621,19 +9623,27 @@ export function getModuleSpecifierEndingPreference(preference: UserPreferences["
96219623

96229624
function inferPreference() {
96239625
let usesJsExtensions = false;
9624-
const specifiers = sourceFile.imports.length ? sourceFile.imports.map(i => i.text) :
9625-
isSourceFileJS(sourceFile) ? getRequiresAtTopOfFile(sourceFile).map(r => r.arguments[0].text) :
9626+
const specifiers = sourceFile.imports.length ? sourceFile.imports :
9627+
isSourceFileJS(sourceFile) ? getRequiresAtTopOfFile(sourceFile).map(r => r.arguments[0]) :
96269628
emptyArray;
96279629
for (const specifier of specifiers) {
9628-
if (pathIsRelative(specifier)) {
9629-
if (fileExtensionIsOneOf(specifier, extensionsNotSupportingExtensionlessResolution)) {
9630+
if (pathIsRelative(specifier.text)) {
9631+
if (
9632+
moduleResolutionIsNodeNext &&
9633+
resolutionMode === ModuleKind.CommonJS &&
9634+
getModeForUsageLocation(sourceFile, specifier, compilerOptions) === ModuleKind.ESNext
9635+
) {
9636+
// We're trying to decide a preference for a CommonJS module specifier, but looking at an ESM import.
9637+
continue;
9638+
}
9639+
if (fileExtensionIsOneOf(specifier.text, extensionsNotSupportingExtensionlessResolution)) {
96309640
// These extensions are not optional, so do not indicate a preference.
96319641
continue;
96329642
}
9633-
if (hasTSFileExtension(specifier)) {
9643+
if (hasTSFileExtension(specifier.text)) {
96349644
return ModuleSpecifierEnding.TsExtension;
96359645
}
9636-
if (hasJSFileExtension(specifier)) {
9646+
if (hasJSFileExtension(specifier.text)) {
96379647
usesJsExtensions = true;
96389648
}
96399649
}

src/services/stringCompletions.ts

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import {
2+
getModuleSpecifierPreferences,
3+
} from "../compiler/moduleSpecifiers";
14
import {
25
addToSeen,
36
altDirectorySeparator,
@@ -52,7 +55,6 @@ import {
5255
getEffectiveTypeRoots,
5356
getEmitModuleResolutionKind,
5457
getLeadingCommentRanges,
55-
getModuleSpecifierEndingPreference,
5658
getOwnKeys,
5759
getPackageJsonTypesVersionsPaths,
5860
getPathComponents,
@@ -744,7 +746,7 @@ function getCompletionEntriesForDirectoryFragment(
744746
continue;
745747
}
746748

747-
const { name, extension } = getFilenameWithExtensionOption(getBaseFileName(filePath), host.getCompilationSettings(), extensionOptions);
749+
const { name, extension } = getFilenameWithExtensionOption(getBaseFileName(filePath), host.getCompilationSettings(), extensionOptions, /*isExportsWildcard*/ false);
748750
result.add(nameAndKind(name, ScriptElementKind.scriptElement, extension));
749751
}
750752
}
@@ -764,7 +766,7 @@ function getCompletionEntriesForDirectoryFragment(
764766
return result;
765767
}
766768

767-
function getFilenameWithExtensionOption(name: string, compilerOptions: CompilerOptions, extensionOptions: ExtensionOptions): { name: string; extension: Extension | undefined; } {
769+
function getFilenameWithExtensionOption(name: string, compilerOptions: CompilerOptions, extensionOptions: ExtensionOptions, isExportsWildcard: boolean): { name: string; extension: Extension | undefined; } {
768770
const nonJsResult = moduleSpecifiers.tryGetRealFileNameForNonJsDeclarationFileName(name);
769771
if (nonJsResult) {
770772
return { name: nonJsResult, extension: tryGetExtensionFromPath(nonJsResult) };
@@ -773,8 +775,19 @@ function getFilenameWithExtensionOption(name: string, compilerOptions: CompilerO
773775
return { name, extension: tryGetExtensionFromPath(name) };
774776
}
775777

776-
const endingPreference = getModuleSpecifierEndingPreference(extensionOptions.endingPreference, extensionOptions.resolutionMode, compilerOptions, extensionOptions.importingSourceFile);
777-
if (endingPreference === ModuleSpecifierEnding.TsExtension) {
778+
let allowedEndings = getModuleSpecifierPreferences(
779+
{ importModuleSpecifierEnding: extensionOptions.endingPreference },
780+
compilerOptions,
781+
extensionOptions.importingSourceFile,
782+
).getAllowedEndingsInPreferredOrder(extensionOptions.resolutionMode);
783+
784+
if (isExportsWildcard) {
785+
// If we're completing `import {} from "foo/|"` and subpaths are available via `"exports": { "./*": "./src/*" }`,
786+
// the completion must be a (potentially extension-swapped) file name. Dropping extensions and index files is not allowed.
787+
allowedEndings = allowedEndings.filter(e => e !== ModuleSpecifierEnding.Minimal && e !== ModuleSpecifierEnding.Index);
788+
}
789+
790+
if (allowedEndings[0] === ModuleSpecifierEnding.TsExtension) {
778791
if (fileExtensionIsOneOf(name, supportedTSImplementationExtensions)) {
779792
return { name, extension: tryGetExtensionFromPath(name) };
780793
}
@@ -785,7 +798,8 @@ function getFilenameWithExtensionOption(name: string, compilerOptions: CompilerO
785798
}
786799

787800
if (
788-
(endingPreference === ModuleSpecifierEnding.Minimal || endingPreference === ModuleSpecifierEnding.Index) &&
801+
!isExportsWildcard &&
802+
(allowedEndings[0] === ModuleSpecifierEnding.Minimal || allowedEndings[0] === ModuleSpecifierEnding.Index) &&
789803
fileExtensionIsOneOf(name, [Extension.Js, Extension.Jsx, Extension.Ts, Extension.Tsx, Extension.Dts])
790804
) {
791805
return { name: removeFileExtension(name), extension: tryGetExtensionFromPath(name) };
@@ -814,12 +828,13 @@ function addCompletionEntriesFromPaths(
814828
const lengthB = typeof patternB === "object" ? patternB.prefix.length : b.length;
815829
return compareValues(lengthB, lengthA);
816830
};
817-
return addCompletionEntriesFromPathsOrExports(result, fragment, baseDirectory, extensionOptions, host, getOwnKeys(paths), getPatternsForKey, comparePaths);
831+
return addCompletionEntriesFromPathsOrExports(result, /*isExports*/ false, fragment, baseDirectory, extensionOptions, host, getOwnKeys(paths), getPatternsForKey, comparePaths);
818832
}
819833

820834
/** @returns whether `fragment` was a match for any `paths` (which should indicate whether any other path completions should be offered) */
821835
function addCompletionEntriesFromPathsOrExports(
822836
result: NameAndKindSet,
837+
isExports: boolean,
823838
fragment: string,
824839
baseDirectory: string,
825840
extensionOptions: ExtensionOptions,
@@ -857,7 +872,7 @@ function addCompletionEntriesFromPathsOrExports(
857872
if (typeof pathPattern === "string" || matchedPath === undefined || comparePaths(key, matchedPath) !== Comparison.GreaterThan) {
858873
pathResults.push({
859874
matchedPattern: isMatch,
860-
results: getCompletionsForPathMapping(keyWithoutLeadingDotSlash, patterns, fragment, baseDirectory, extensionOptions, host)
875+
results: getCompletionsForPathMapping(keyWithoutLeadingDotSlash, patterns, fragment, baseDirectory, extensionOptions, isExports && isMatch, host)
861876
.map(({ name, kind, extension }) => nameAndKind(name, kind, extension)),
862877
});
863878
}
@@ -956,6 +971,7 @@ function getCompletionEntriesForNonRelativeModules(
956971
const conditions = getConditions(compilerOptions, mode);
957972
addCompletionEntriesFromPathsOrExports(
958973
result,
974+
/*isExports*/ true,
959975
fragmentSubpath,
960976
packageDirectory,
961977
extensionOptions,
@@ -1001,6 +1017,7 @@ function getCompletionsForPathMapping(
10011017
fragment: string,
10021018
packageDirectory: string,
10031019
extensionOptions: ExtensionOptions,
1020+
isExportsWildcard: boolean,
10041021
host: LanguageServiceHost,
10051022
): readonly NameAndKind[] {
10061023
if (!endsWith(path, "*")) {
@@ -1012,9 +1029,9 @@ function getCompletionsForPathMapping(
10121029
const remainingFragment = tryRemovePrefix(fragment, pathPrefix);
10131030
if (remainingFragment === undefined) {
10141031
const starIsFullPathComponent = path[path.length - 2] === "/";
1015-
return starIsFullPathComponent ? justPathMappingName(pathPrefix, ScriptElementKind.directory) : flatMap(patterns, pattern => getModulesForPathsPattern("", packageDirectory, pattern, extensionOptions, host)?.map(({ name, ...rest }) => ({ name: pathPrefix + name, ...rest })));
1032+
return starIsFullPathComponent ? justPathMappingName(pathPrefix, ScriptElementKind.directory) : flatMap(patterns, pattern => getModulesForPathsPattern("", packageDirectory, pattern, extensionOptions, isExportsWildcard, host)?.map(({ name, ...rest }) => ({ name: pathPrefix + name, ...rest })));
10161033
}
1017-
return flatMap(patterns, pattern => getModulesForPathsPattern(remainingFragment, packageDirectory, pattern, extensionOptions, host));
1034+
return flatMap(patterns, pattern => getModulesForPathsPattern(remainingFragment, packageDirectory, pattern, extensionOptions, isExportsWildcard, host));
10181035

10191036
function justPathMappingName(name: string, kind: ScriptElementKind.directory | ScriptElementKind.scriptElement): readonly NameAndKind[] {
10201037
return startsWith(name, fragment) ? [{ name: removeTrailingDirectorySeparator(name), kind, extension: undefined }] : emptyArray;
@@ -1026,6 +1043,7 @@ function getModulesForPathsPattern(
10261043
packageDirectory: string,
10271044
pattern: string,
10281045
extensionOptions: ExtensionOptions,
1046+
isExportsWildcard: boolean,
10291047
host: LanguageServiceHost,
10301048
): readonly NameAndKind[] | undefined {
10311049
if (!host.readDirectory) {
@@ -1074,7 +1092,7 @@ function getModulesForPathsPattern(
10741092
if (containsSlash(trimmedWithPattern)) {
10751093
return directoryResult(getPathComponents(removeLeadingDirectorySeparator(trimmedWithPattern))[1]);
10761094
}
1077-
const { name, extension } = getFilenameWithExtensionOption(trimmedWithPattern, host.getCompilationSettings(), extensionOptions);
1095+
const { name, extension } = getFilenameWithExtensionOption(trimmedWithPattern, host.getCompilationSettings(), extensionOptions, isExportsWildcard);
10781096
return nameAndKind(name, ScriptElementKind.scriptElement, extension);
10791097
}
10801098
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/// <reference path="fourslash.ts" />
2+
3+
// @module: preserve
4+
// @moduleResolution: bundler
5+
// @allowImportingTsExtensions: true
6+
// @jsx: react
7+
8+
// @Filename: /node_modules/repo/package.json
9+
//// {
10+
//// "name": "repo",
11+
//// "exports": {
12+
//// "./*": "./src/*"
13+
//// }
14+
//// }
15+
16+
// @Filename: /node_modules/repo/src/card.tsx
17+
//// export {};
18+
19+
// @Filename: /main.ts
20+
//// import { } from "repo//**/";
21+
22+
verify.completions({
23+
marker: "",
24+
isNewIdentifierLocation: true,
25+
exact: [
26+
{ name: "card.tsx", kind: "script", kindModifiers: ".tsx" },
27+
],
28+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/// <reference path="fourslash.ts" />
2+
3+
// @module: preserve
4+
// @moduleResolution: bundler
5+
// @jsx: react
6+
7+
// @Filename: /node_modules/repo/package.json
8+
//// {
9+
//// "name": "repo",
10+
//// "exports": {
11+
//// "./*": "./src/*"
12+
//// }
13+
//// }
14+
15+
// @Filename: /node_modules/repo/src/card.tsx
16+
//// export {};
17+
18+
// @Filename: /main.ts
19+
//// import { } from "repo//**/";
20+
21+
verify.completions({
22+
marker: "",
23+
isNewIdentifierLocation: true,
24+
exact: [
25+
{ name: "card.js", kind: "script", kindModifiers: ".js" },
26+
],
27+
});

0 commit comments

Comments
 (0)