diff --git a/src/compiler/moduleSpecifiers.ts b/src/compiler/moduleSpecifiers.ts index b243ff348146f..625faf81b381d 100644 --- a/src/compiler/moduleSpecifiers.ts +++ b/src/compiler/moduleSpecifiers.ts @@ -3,6 +3,7 @@ namespace ts.moduleSpecifiers { export interface ModuleSpecifierPreferences { readonly importModuleSpecifierPreference?: "relative" | "non-relative"; + readonly includeExtensionInImports?: boolean; } // Note: importingSourceFile is just for usesJsExtensionOnImports @@ -17,7 +18,7 @@ namespace ts.moduleSpecifiers { ): string { const info = getInfo(compilerOptions, importingSourceFile, importingSourceFileName, host); const modulePaths = getAllModulePaths(files, toFileName, info.getCanonicalFileName, host); - return firstDefined(modulePaths, moduleFileName => getGlobalModuleSpecifier(moduleFileName, info, host, compilerOptions)) || + return firstDefined(modulePaths, moduleFileName => getGlobalModuleSpecifier(moduleFileName, info, host, compilerOptions, preferences)) || first(getLocalModuleSpecifiers(toFileName, info, compilerOptions, preferences)); } @@ -39,46 +40,47 @@ namespace ts.moduleSpecifiers { } const modulePaths = getAllModulePaths(files, getSourceFileOfNode(moduleSymbol.valueDeclaration).fileName, info.getCanonicalFileName, host); - const global = mapDefined(modulePaths, moduleFileName => getGlobalModuleSpecifier(moduleFileName, info, host, compilerOptions)); + const global = mapDefined(modulePaths, moduleFileName => getGlobalModuleSpecifier(moduleFileName, info, host, compilerOptions, preferences)); return global.length ? global.map(g => [g]) : modulePaths.map(moduleFileName => getLocalModuleSpecifiers(moduleFileName, info, compilerOptions, preferences)); } interface Info { readonly moduleResolutionKind: ModuleResolutionKind; - readonly addJsExtension: boolean; + readonly addExtension: boolean; readonly getCanonicalFileName: GetCanonicalFileName; readonly sourceDirectory: Path; } // importingSourceFileName is separate because getEditsForFileRename may need to specify an updated path function getInfo(compilerOptions: CompilerOptions, importingSourceFile: SourceFile, importingSourceFileName: Path, host: ModuleSpecifierResolutionHost): Info { const moduleResolutionKind = getEmitModuleResolutionKind(compilerOptions); - const addJsExtension = usesJsExtensionOnImports(importingSourceFile); + const addExtension = usesExtensionOnImports(importingSourceFile); const getCanonicalFileName = createGetCanonicalFileName(host.useCaseSensitiveFileNames ? host.useCaseSensitiveFileNames() : true); const sourceDirectory = getDirectoryPath(importingSourceFileName); - return { moduleResolutionKind, addJsExtension, getCanonicalFileName, sourceDirectory }; + return { moduleResolutionKind, addExtension, getCanonicalFileName, sourceDirectory }; } function getGlobalModuleSpecifier( moduleFileName: string, - { addJsExtension, getCanonicalFileName, sourceDirectory }: Info, + { addExtension, getCanonicalFileName, sourceDirectory }: Info, host: ModuleSpecifierResolutionHost, compilerOptions: CompilerOptions, + preferences: ModuleSpecifierPreferences ) { - return tryGetModuleNameFromTypeRoots(compilerOptions, host, getCanonicalFileName, moduleFileName, addJsExtension) + return tryGetModuleNameFromTypeRoots(compilerOptions, host, getCanonicalFileName, moduleFileName, addExtension, preferences) || tryGetModuleNameAsNodeModule(compilerOptions, moduleFileName, host, getCanonicalFileName, sourceDirectory); } function getLocalModuleSpecifiers( moduleFileName: string, - { moduleResolutionKind, addJsExtension, getCanonicalFileName, sourceDirectory }: Info, + { moduleResolutionKind, addExtension, getCanonicalFileName, sourceDirectory }: Info, compilerOptions: CompilerOptions, preferences: ModuleSpecifierPreferences, ): ReadonlyArray { const { baseUrl, paths, rootDirs } = compilerOptions; const relativePath = rootDirs && tryGetModuleNameFromRootDirs(rootDirs, moduleFileName, sourceDirectory, getCanonicalFileName) || - removeExtensionAndIndexPostFix(ensurePathIsNonModuleName(getRelativePathFromDirectory(sourceDirectory, moduleFileName, getCanonicalFileName)), moduleResolutionKind, addJsExtension); + removeExtensionAndIndexPostFix(ensurePathIsNonModuleName(getRelativePathFromDirectory(sourceDirectory, moduleFileName, getCanonicalFileName)), moduleResolutionKind, addExtension, compilerOptions, preferences); if (!baseUrl || preferences.importModuleSpecifierPreference === "relative") { return [relativePath]; } @@ -88,7 +90,7 @@ namespace ts.moduleSpecifiers { return [relativePath]; } - const importRelativeToBaseUrl = removeExtensionAndIndexPostFix(relativeToBaseUrl, moduleResolutionKind, addJsExtension); + const importRelativeToBaseUrl = removeExtensionAndIndexPostFix(relativeToBaseUrl, moduleResolutionKind, addExtension, compilerOptions, preferences); if (paths) { const fromPaths = tryGetModuleNameFromPaths(removeFileExtension(relativeToBaseUrl), importRelativeToBaseUrl, paths); if (fromPaths) { @@ -138,8 +140,8 @@ namespace ts.moduleSpecifiers { return relativeFirst ? [relativePath, importRelativeToBaseUrl] : [importRelativeToBaseUrl, relativePath]; } - function usesJsExtensionOnImports({ imports }: SourceFile): boolean { - return firstDefined(imports, ({ text }) => pathIsRelative(text) ? fileExtensionIs(text, Extension.Js) : undefined) || false; + function usesExtensionOnImports({ imports }: SourceFile): boolean { + return firstDefined(imports, ({ text }) => pathIsRelative(text) ? fileExtensionIsOneOf(text, [Extension.Js, Extension.Jsx]) : undefined) || false; } function discoverProbableSymlinks(files: ReadonlyArray, getCanonicalFileName: (file: string) => string, host: ModuleSpecifierResolutionHost) { @@ -256,14 +258,15 @@ namespace ts.moduleSpecifiers { host: GetEffectiveTypeRootsHost, getCanonicalFileName: (file: string) => string, moduleFileName: string, - addJsExtension: boolean, + addExtension: boolean, + preferences: ModuleSpecifierPreferences ): string | undefined { const roots = getEffectiveTypeRoots(options, host); return firstDefined(roots, unNormalizedTypeRoot => { const typeRoot = toPath(unNormalizedTypeRoot, /*basePath*/ undefined, getCanonicalFileName); if (startsWith(moduleFileName, typeRoot)) { // For a type definition, we can strip `/index` even with classic resolution. - return removeExtensionAndIndexPostFix(moduleFileName.substring(typeRoot.length + 1), ModuleResolutionKind.NodeJs, addJsExtension); + return removeExtensionAndIndexPostFix(moduleFileName.substring(typeRoot.length + 1), ModuleResolutionKind.NodeJs, addExtension, options, preferences); } }); } @@ -408,10 +411,25 @@ namespace ts.moduleSpecifiers { }); } - function removeExtensionAndIndexPostFix(fileName: string, moduleResolutionKind: ModuleResolutionKind, addJsExtension: boolean): string { + function tryGetActualExtension(text: string, compilerOptions: CompilerOptions) { + const extension = pathIsRelative(text) && tryGetExtensionFromPath(text); + if (!extension) return undefined; + + switch (extension) { + case Extension.Ts: + return Extension.Js; + case Extension.Tsx: + return compilerOptions.jsx === JsxEmit.React || compilerOptions.jsx === JsxEmit.ReactNative ? Extension.Js : Extension.Jsx; + default: + return extension; + } + } + + function removeExtensionAndIndexPostFix(fileName: string, moduleResolutionKind: ModuleResolutionKind, addJsExtension: boolean, compilerOptions: CompilerOptions, preferences: ModuleSpecifierPreferences): string { const noExtension = removeFileExtension(fileName); - return addJsExtension - ? noExtension + ".js" + const actualExtension = tryGetActualExtension(fileName, compilerOptions); + return (actualExtension && (preferences.includeExtensionInImports !== undefined && preferences.includeExtensionInImports || addJsExtension)) + ? noExtension + actualExtension : moduleResolutionKind === ModuleResolutionKind.NodeJs ? removeSuffix(noExtension, "/index") : noExtension; diff --git a/src/server/protocol.ts b/src/server/protocol.ts index a04b7fe5da8b8..e92cfc714dec7 100644 --- a/src/server/protocol.ts +++ b/src/server/protocol.ts @@ -2802,6 +2802,7 @@ namespace ts.server.protocol { readonly includeCompletionsWithInsertText?: boolean; readonly importModuleSpecifierPreference?: "relative" | "non-relative"; readonly allowTextChangesInNewFiles?: boolean; + readonly includeExtensionInImports?: boolean; } export interface CompilerOptions { diff --git a/src/services/types.ts b/src/services/types.ts index 51c98724c0957..675802f7215e9 100644 --- a/src/services/types.ts +++ b/src/services/types.ts @@ -240,6 +240,7 @@ namespace ts { readonly includeCompletionsWithInsertText?: boolean; readonly importModuleSpecifierPreference?: "relative" | "non-relative"; readonly allowTextChangesInNewFiles?: boolean; + readonly includeExtensionInImports?: boolean; } /* @internal */ export const emptyOptions = {}; diff --git a/tests/baselines/reference/api/tsserverlibrary.d.ts b/tests/baselines/reference/api/tsserverlibrary.d.ts index 5118830e840ce..1332580c1c588 100644 --- a/tests/baselines/reference/api/tsserverlibrary.d.ts +++ b/tests/baselines/reference/api/tsserverlibrary.d.ts @@ -4811,6 +4811,7 @@ declare namespace ts { readonly includeCompletionsWithInsertText?: boolean; readonly importModuleSpecifierPreference?: "relative" | "non-relative"; readonly allowTextChangesInNewFiles?: boolean; + readonly includeExtensionInImports?: boolean; } interface LanguageService { cleanupSemanticCache(): void; @@ -7962,6 +7963,7 @@ declare namespace ts.server.protocol { readonly includeCompletionsWithInsertText?: boolean; readonly importModuleSpecifierPreference?: "relative" | "non-relative"; readonly allowTextChangesInNewFiles?: boolean; + readonly includeExtensionInImports?: boolean; } interface CompilerOptions { allowJs?: boolean; diff --git a/tests/baselines/reference/api/typescript.d.ts b/tests/baselines/reference/api/typescript.d.ts index 488f7c85a69d7..91c121a227d1b 100644 --- a/tests/baselines/reference/api/typescript.d.ts +++ b/tests/baselines/reference/api/typescript.d.ts @@ -4811,6 +4811,7 @@ declare namespace ts { readonly includeCompletionsWithInsertText?: boolean; readonly importModuleSpecifierPreference?: "relative" | "non-relative"; readonly allowTextChangesInNewFiles?: boolean; + readonly includeExtensionInImports?: boolean; } interface LanguageService { cleanupSemanticCache(): void; diff --git a/tests/cases/fourslash/importNameCodeFix_ExtensionPreference.ts b/tests/cases/fourslash/importNameCodeFix_ExtensionPreference.ts new file mode 100644 index 0000000000000..87ef25258f6ba --- /dev/null +++ b/tests/cases/fourslash/importNameCodeFix_ExtensionPreference.ts @@ -0,0 +1,114 @@ +/// + +// @moduleResolution: node +// @noLib: true +// @allowJs: true +// @checkJs: true +// @jsx: preserve + +// @Filename: /a.js +////export function a() {} + +// @Filename: /b.ts +////export function b() {} + +// @Filename: /c.jsx +////export function c() {} + +// @Filename: /d.tsx +////export function d() {} + +// @Filename: /normalExt.ts +////a; +////b; +////c; +////d; + +// @Filename: /includeExt.ts +////a; +////b; +////c; +////d; + +// @Filename: /includeExt.js +////a; +////b; +////c; +////d; + + +goTo.file("/normalExt.ts"); +verify.importFixAtPosition([ + `import { a } from "./a"; + +a; +b; +c; +d;`, `import { b } from "./b"; + +a; +b; +c; +d;`, `import { c } from "./c"; + +a; +b; +c; +d;`, `import { d } from "./d"; + +a; +b; +c; +d;`]); + +goTo.file("/includeExt.ts"); +verify.importFixAtPosition([ + `import { a } from "./a.js"; + +a; +b; +c; +d;`, `import { b } from "./b.js"; + +a; +b; +c; +d;`, `import { c } from "./c.jsx"; + +a; +b; +c; +d;`, `import { d } from "./d.jsx"; + +a; +b; +c; +d;`], /* errorCode */ undefined, { + includeExtensionInImports: true + }); + +goTo.file("/includeExt.js"); +verify.importFixAtPosition([ + `import { a } from "./a.js"; + +a; +b; +c; +d;`, `import { b } from "./b.js"; + +a; +b; +c; +d;`, `import { c } from "./c.jsx"; + +a; +b; +c; +d;`, `import { d } from "./d.jsx"; + +a; +b; +c; +d;`], /* errorCode */ undefined, { + includeExtensionInImports: true + }); \ No newline at end of file