diff --git a/src/compiler/checker.ts b/src/compiler/checker.ts index 963981dde4743..3672f6e3803d9 100644 --- a/src/compiler/checker.ts +++ b/src/compiler/checker.ts @@ -139,6 +139,7 @@ import { ElementFlags, EmitFlags, EmitHint, + emitModuleKindIsNonNodeESM, EmitResolver, EmitTextWriter, emptyArray, @@ -458,6 +459,7 @@ import { isConstructorTypeNode, isConstTypeReference, isDeclaration, + isDeclarationFileName, isDeclarationName, isDeclarationReadonly, isDecorator, @@ -897,6 +899,7 @@ import { setTextRangePosEnd, setValueDeclaration, ShorthandPropertyAssignment, + shouldAllowImportingTsExtension, shouldPreserveConstEnums, Signature, SignatureDeclaration, @@ -4727,6 +4730,7 @@ export function createTypeChecker(host: TypeCheckerHost): TypeChecker { (isModuleDeclaration(location) ? location : location.parent && isModuleDeclaration(location.parent) && location.parent.name === location ? location.parent : undefined)?.name || (isLiteralImportTypeNode(location) ? location : undefined)?.argument.literal; const mode = contextSpecifier && isStringLiteralLike(contextSpecifier) ? getModeForUsageLocation(currentSourceFile, contextSpecifier) : currentSourceFile.impliedNodeFormat; + const moduleResolutionKind = getEmitModuleResolutionKind(compilerOptions); const resolvedModule = getResolvedModule(currentSourceFile, moduleReference, mode); const resolutionDiagnostic = resolvedModule && getResolutionDiagnostic(compilerOptions, resolvedModule); const sourceFile = resolvedModule @@ -4737,11 +4741,28 @@ export function createTypeChecker(host: TypeCheckerHost): TypeChecker { if (resolutionDiagnostic) { error(errorNode, resolutionDiagnostic, moduleReference, resolvedModule.resolvedFileName); } + + if (resolvedModule.resolvedUsingTsExtension && isDeclarationFileName(moduleReference)) { + const importOrExport = + findAncestor(location, isImportDeclaration)?.importClause || + findAncestor(location, or(isImportEqualsDeclaration, isExportDeclaration)); + if (importOrExport && !importOrExport.isTypeOnly || findAncestor(location, isImportCall)) { + error( + errorNode, + Diagnostics.A_declaration_file_cannot_be_imported_without_import_type_Did_you_mean_to_import_an_implementation_file_0_instead, + getSuggestedImportSource(Debug.checkDefined(tryExtractTSExtension(moduleReference)))); + } + } + else if (resolvedModule.resolvedUsingTsExtension && !shouldAllowImportingTsExtension(compilerOptions, currentSourceFile.fileName)) { + const tsExtension = Debug.checkDefined(tryExtractTSExtension(moduleReference)); + error(errorNode, Diagnostics.An_import_path_can_only_end_with_a_0_extension_when_allowImportingTsExtensions_is_enabled, tsExtension); + } + if (sourceFile.symbol) { if (resolvedModule.isExternalLibraryImport && !resolutionExtensionIsTSOrJson(resolvedModule.extension)) { errorOnImplicitAnyModule(/*isError*/ false, errorNode, resolvedModule, moduleReference); } - if (getEmitModuleResolutionKind(compilerOptions) === ModuleResolutionKind.Node16 || getEmitModuleResolutionKind(compilerOptions) === ModuleResolutionKind.NodeNext) { + if (moduleResolutionKind === ModuleResolutionKind.Node16 || moduleResolutionKind === ModuleResolutionKind.NodeNext) { const isSyncImport = (currentSourceFile.impliedNodeFormat === ModuleKind.CommonJS && !findAncestor(location, isImportCall)) || !!findAncestor(location, isImportEqualsDeclaration); const overrideClauseHost = findAncestor(location, l => isImportTypeNode(l) || isExportDeclaration(l) || isImportDeclaration(l)) as ImportTypeNode | ImportDeclaration | ExportDeclaration | undefined; const overrideClause = overrideClauseHost && isImportTypeNode(overrideClauseHost) ? overrideClauseHost.assertions?.assertClause : overrideClauseHost?.assertClause; @@ -4849,25 +4870,14 @@ export function createTypeChecker(host: TypeCheckerHost): TypeChecker { else { const tsExtension = tryExtractTSExtension(moduleReference); const isExtensionlessRelativePathImport = pathIsRelative(moduleReference) && !hasExtension(moduleReference); - const moduleResolutionKind = getEmitModuleResolutionKind(compilerOptions); const resolutionIsNode16OrNext = moduleResolutionKind === ModuleResolutionKind.Node16 || moduleResolutionKind === ModuleResolutionKind.NodeNext; if (tsExtension) { - const diag = Diagnostics.An_import_path_cannot_end_with_a_0_extension_Consider_importing_1_instead; - const importSourceWithoutExtension = removeExtension(moduleReference, tsExtension); - let replacedImportSource = importSourceWithoutExtension; - /** - * Direct users to import source with .js extension if outputting an ES module. - * @see https://github.com/microsoft/TypeScript/issues/42151 - */ - if (moduleKind >= ModuleKind.ES2015) { - replacedImportSource += tsExtension === Extension.Mts ? ".mjs" : tsExtension === Extension.Cts ? ".cjs" : ".js"; - } - error(errorNode, diag, tsExtension, replacedImportSource); + errorOnTSExtensionImport(tsExtension); } else if (!compilerOptions.resolveJsonModule && fileExtensionIs(moduleReference, Extension.Json) && - getEmitModuleResolutionKind(compilerOptions) !== ModuleResolutionKind.Classic && + moduleResolutionKind !== ModuleResolutionKind.Classic && hasJsonModuleEmitEnabled(compilerOptions)) { error(errorNode, Diagnostics.Cannot_find_module_0_Consider_using_resolveJsonModule_to_import_module_with_json_extension, moduleReference); } @@ -4889,6 +4899,23 @@ export function createTypeChecker(host: TypeCheckerHost): TypeChecker { } } return undefined; + + function errorOnTSExtensionImport(tsExtension: string) { + const diag = Diagnostics.An_import_path_cannot_end_with_a_0_extension_Consider_importing_1_instead; + error(errorNode, diag, tsExtension, getSuggestedImportSource(tsExtension)); + } + + function getSuggestedImportSource(tsExtension: string) { + const importSourceWithoutExtension = removeExtension(moduleReference, tsExtension); + /** + * Direct users to import source with .js extension if outputting an ES module. + * @see https://github.com/microsoft/TypeScript/issues/42151 + */ + if (emitModuleKindIsNonNodeESM(moduleKind) || mode === ModuleKind.ESNext) { + return importSourceWithoutExtension + (tsExtension === Extension.Mts ? ".mjs" : tsExtension === Extension.Cts ? ".cjs" : ".js"); + } + return importSourceWithoutExtension; + } } function errorOnImplicitAnyModule(isError: boolean, errorNode: Node, { packageId, resolvedFileName }: ResolvedModuleFull, moduleReference: string): void { diff --git a/src/compiler/commandLineParser.ts b/src/compiler/commandLineParser.ts index 97a09f716345e..6003dbe036561 100644 --- a/src/compiler/commandLineParser.ts +++ b/src/compiler/commandLineParser.ts @@ -1081,6 +1081,14 @@ const commandOptionsWithoutBuild: CommandLineOption[] = [ category: Diagnostics.Modules, description: Diagnostics.List_of_file_name_suffixes_to_search_when_resolving_a_module, }, + // { + // name: "allowImportingTsExtensions", + // type: "boolean", + // affectsModuleResolution: true, + // category: Diagnostics.Modules, + // description: Diagnostics.Allow_imports_to_include_TypeScript_file_extensions_Requires_moduleResolution_minimal_and_either_noEmit_or_emitDeclarationOnly_to_be_set, + // defaultValueDescription: false, + // }, // Source Maps { diff --git a/src/compiler/diagnosticMessages.json b/src/compiler/diagnosticMessages.json index e146cce88c8cb..438bbf2f7014e 100644 --- a/src/compiler/diagnosticMessages.json +++ b/src/compiler/diagnosticMessages.json @@ -3563,6 +3563,10 @@ "category": "Error", "code": 2845 }, + "A declaration file cannot be imported without 'import type'. Did you mean to import an implementation file '{0}' instead?": { + "category": "Error", + "code": 2846 + }, "Import declaration '{0}' is using private name '{1}'.": { "category": "Error", @@ -4221,6 +4225,14 @@ "category": "Error", "code": 5095 }, + "Option 'allowImportingTsExtensions' can only be used when 'moduleResolution' is set to 'minimal' and either 'noEmit' or 'emitDeclarationOnly' is set.": { + "category": "Error", + "code": 5096 + }, + "An import path can only end with a '{0}' extension when 'allowImportingTsExtensions' is enabled.": { + "category": "Error", + "code": 5097 + }, "Generates a sourcemap for each corresponding '.d.ts' file.": { "category": "Message", @@ -4443,10 +4455,6 @@ "category": "Message", "code": 6066 }, - "Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6).": { - "category": "Message", - "code": 6069 - }, "Initializes a TypeScript project and creates a tsconfig.json file.": { "category": "Message", "code": 6070 @@ -5430,6 +5438,10 @@ "category": "Message", "code": 6406 }, + "Allow imports to include TypeScript file extensions. Requires '--moduleResolution minimal' and either '--noEmit' or '--emitDeclarationOnly' to be set.": { + "category": "Message", + "code": 6407 + }, "The expected type comes from property '{0}' which is declared here on type '{1}'": { "category": "Message", diff --git a/src/compiler/moduleNameResolver.ts b/src/compiler/moduleNameResolver.ts index fd9cf539fc333..da355d8f05957 100644 --- a/src/compiler/moduleNameResolver.ts +++ b/src/compiler/moduleNameResolver.ts @@ -53,6 +53,7 @@ import { hasTrailingDirectorySeparator, hostGetCanonicalFileName, isArray, + isDeclarationFileName, isExternalModuleNameRelative, isRootedDiskPath, isString, @@ -94,6 +95,7 @@ import { startsWith, stringContains, supportedDeclarationExtensions, + supportedTSExtensionsFlat, supportedTSImplementationExtensions, toPath, tryExtractTSExtension, @@ -128,7 +130,7 @@ function withPackageId(packageInfo: PackageJsonInfo | undefined, r: PathAndExten }; } } - return r && { path: r.path, extension: r.ext, packageId }; + return r && { path: r.path, extension: r.ext, packageId, resolvedUsingTsExtension: r.resolvedUsingTsExtension }; } function noPackageId(r: PathAndExtension | undefined): Resolved | undefined { @@ -138,7 +140,7 @@ function noPackageId(r: PathAndExtension | undefined): Resolved | undefined { function removeIgnoredPackageId(r: Resolved | undefined): PathAndExtension | undefined { if (r) { Debug.assert(r.packageId === undefined); - return { path: r.path, ext: r.extension }; + return { path: r.path, ext: r.extension, resolvedUsingTsExtension: r.resolvedUsingTsExtension }; } } @@ -157,6 +159,7 @@ interface Resolved { * Note: This is a file name with preserved original casing, not a normalized `Path`. */ originalPath?: string | true; + resolvedUsingTsExtension: boolean | undefined; } /** Result of trying to resolve a module at a file. Needs to have 'packageId' added later. */ @@ -164,6 +167,7 @@ interface PathAndExtension { path: string; // (Use a different name than `extension` to make sure Resolved isn't assignable to PathAndExtension.) ext: Extension; + resolvedUsingTsExtension: boolean | undefined; } /** @@ -215,7 +219,14 @@ function createResolvedModuleWithFailedLookupLocations( return resultFromCache; } return { - resolvedModule: resolved && { resolvedFileName: resolved.path, originalPath: resolved.originalPath === true ? undefined : resolved.originalPath, extension: resolved.extension, isExternalLibraryImport, packageId: resolved.packageId }, + resolvedModule: resolved && { + resolvedFileName: resolved.path, + originalPath: resolved.originalPath === true ? undefined : resolved.originalPath, + extension: resolved.extension, + isExternalLibraryImport, + packageId: resolved.packageId, + resolvedUsingTsExtension: !!resolved.resolvedUsingTsExtension, + }, failedLookupLocations: initializeResolutionField(failedLookupLocations), affectingLocations: initializeResolutionField(affectingLocations), resolutionDiagnostics: initializeResolutionField(diagnostics), @@ -1795,7 +1806,10 @@ function loadModuleFromFile(extensions: Extensions, candidate: string, onlyRecor function loadModuleFromFileNoImplicitExtensions(extensions: Extensions, candidate: string, onlyRecordFailures: boolean, state: ModuleResolutionState): PathAndExtension | undefined { // If that didn't work, try stripping a ".js" or ".jsx" extension and replacing it with a TypeScript one; // e.g. "./foo.js" can be matched by "./foo.ts" or "./foo.d.ts" - if (hasJSFileExtension(candidate) || extensions & Extensions.Json && fileExtensionIs(candidate, Extension.Json)) { + if (hasJSFileExtension(candidate) || + extensions & Extensions.Json && fileExtensionIs(candidate, Extension.Json) || + extensions & (Extensions.TypeScript | Extensions.Declaration) && moduleResolutionSupportsResolvingTsExtensions(state.compilerOptions) && fileExtensionIsOneOf(candidate, supportedTSExtensionsFlat) + ) { const extensionless = removeFileExtension(candidate); const extension = candidate.substring(extensionless.length); if (state.traceEnabled) { @@ -1810,7 +1824,7 @@ function loadJSOrExactTSFileName(extensions: Extensions, candidate: string, only extensions & Extensions.Declaration && fileExtensionIsOneOf(candidate, supportedDeclarationExtensions) ) { const result = tryFile(candidate, onlyRecordFailures, state); - return result !== undefined ? { path: candidate, ext: tryExtractTSExtension(candidate) as Extension } : undefined; + return result !== undefined ? { path: candidate, ext: tryExtractTSExtension(candidate) as Extension, resolvedUsingTsExtension: undefined } : undefined; } return loadModuleFromFileNoImplicitExtensions(extensions, candidate, onlyRecordFailures, state); @@ -1854,6 +1868,13 @@ function tryAddingExtensions(candidate: string, extensions: Extensions, original if (result) return result; } return undefined; + case Extension.Ts: + case Extension.Tsx: + case Extension.Dts: + if (moduleResolutionSupportsResolvingTsExtensions(state.compilerOptions) && extensionIsOk(extensions, originalExtension)) { + return tryExtension(originalExtension, /*resolvedUsingTsExtension*/ true); + } + // falls through default: return extensions & Extensions.TypeScript && (tryExtension(Extension.Ts) || tryExtension(Extension.Tsx)) || extensions & Extensions.Declaration && tryExtension(Extension.Dts) @@ -1863,9 +1884,9 @@ function tryAddingExtensions(candidate: string, extensions: Extensions, original } - function tryExtension(ext: Extension): PathAndExtension | undefined { + function tryExtension(ext: Extension, resolvedUsingTsExtension?: boolean): PathAndExtension | undefined { const path = tryFile(candidate + ext, onlyRecordFailures, state); - return path === undefined ? undefined : { path, ext }; + return path === undefined ? undefined : { path, ext, resolvedUsingTsExtension }; } } @@ -2179,9 +2200,9 @@ function loadNodeModuleFromDirectoryWorker(extensions: Extensions, candidate: st } /** Resolve from an arbitrarily specified file. Return `undefined` if it has an unsupported extension. */ -function resolvedIfExtensionMatches(extensions: Extensions, path: string): PathAndExtension | undefined { +function resolvedIfExtensionMatches(extensions: Extensions, path: string, resolvedUsingTsExtension?: boolean): PathAndExtension | undefined { const ext = tryGetExtensionFromPath(path); - return ext !== undefined && extensionIsOk(extensions, ext) ? { path, ext } : undefined; + return ext !== undefined && extensionIsOk(extensions, ext) ? { path, ext, resolvedUsingTsExtension } : undefined; } /** True if `extension` is one of the supported `extensions`. */ @@ -2372,7 +2393,13 @@ function getLoadModuleFromTargetImportOrExport(extensions: Extensions, state: Mo traceIfEnabled(state, Diagnostics.Using_0_subpath_1_with_target_2, "imports", key, combinedLookup); traceIfEnabled(state, Diagnostics.Resolving_module_0_from_1, combinedLookup, scope.packageDirectory + "/"); const result = nodeModuleNameResolverWorker(state.features, combinedLookup, scope.packageDirectory + "/", state.compilerOptions, state.host, cache, extensions, /*isConfigLookup*/ false, redirectedReference); - return toSearchResult(result.resolvedModule ? { path: result.resolvedModule.resolvedFileName, extension: result.resolvedModule.extension, packageId: result.resolvedModule.packageId, originalPath: result.resolvedModule.originalPath } : undefined); + return toSearchResult(result.resolvedModule ? { + path: result.resolvedModule.resolvedFileName, + extension: result.resolvedModule.extension, + packageId: result.resolvedModule.packageId, + originalPath: result.resolvedModule.originalPath, + resolvedUsingTsExtension: result.resolvedModule.resolvedUsingTsExtension + } : undefined); } if (state.traceEnabled) { trace(state.host, Diagnostics.package_json_scope_0_has_invalid_type_for_target_of_specifier_1, scope.packageDirectory, moduleName); @@ -2753,7 +2780,7 @@ function tryLoadModuleUsingPaths(extensions: Extensions, moduleName: string, bas if (extension !== undefined) { const path = tryFile(candidate, onlyRecordFailures, state); if (path !== undefined) { - return noPackageId({ path, ext: extension }); + return noPackageId({ path, ext: extension, resolvedUsingTsExtension: undefined }); } } return loader(extensions, candidate, onlyRecordFailures || !directoryProbablyExists(getDirectoryPath(candidate), state.host), state); @@ -2813,7 +2840,15 @@ function tryFindNonRelativeModuleNameInCache(cache: NonRelativeModuleNameResolut trace(state.host, Diagnostics.Resolution_for_module_0_was_found_in_cache_from_location_1, moduleName, containingDirectory); } state.resultFromCache = result; - return { value: result.resolvedModule && { path: result.resolvedModule.resolvedFileName, originalPath: result.resolvedModule.originalPath || true, extension: result.resolvedModule.extension, packageId: result.resolvedModule.packageId } }; + return { + value: result.resolvedModule && { + path: result.resolvedModule.resolvedFileName, + originalPath: result.resolvedModule.originalPath || true, + extension: result.resolvedModule.extension, + packageId: result.resolvedModule.packageId, + resolvedUsingTsExtension: result.resolvedModule.resolvedUsingTsExtension + } + }; } } @@ -2880,6 +2915,18 @@ export function classicNameResolver(moduleName: string, containingFile: string, } } +export function moduleResolutionSupportsResolvingTsExtensions(_compilerOptions: CompilerOptions) { + return false; +} + +// Program errors validate that `noEmit` or `emitDeclarationOnly` is also set, +// so this function doesn't check them to avoid propagating errors. +export function shouldAllowImportingTsExtension(compilerOptions: CompilerOptions, fromFileName?: string) { + return moduleResolutionSupportsResolvingTsExtensions(compilerOptions) && ( + !!compilerOptions.allowImportingTsExtensions || + fromFileName && isDeclarationFileName(fromFileName)); +} + /** * A host may load a module from a global cache of typings. * This is the minumum code needed to expose that functionality; the rest is in the host. diff --git a/src/compiler/moduleSpecifiers.ts b/src/compiler/moduleSpecifiers.ts index c21a26c7dcb28..e6905e149908a 100644 --- a/src/compiler/moduleSpecifiers.ts +++ b/src/compiler/moduleSpecifiers.ts @@ -34,9 +34,9 @@ import { GetCanonicalFileName, getDirectoryPath, getEmitModuleResolutionKind, - getImpliedNodeFormatForFile, getModeForResolutionAtIndex, getModuleNameStringLiteralAt, + getModuleSpecifierEndingPreference, getNodeModulePathParts, getNormalizedAbsolutePath, getOwnKeys, @@ -71,9 +71,9 @@ import { ModuleDeclaration, ModuleKind, ModulePath, - ModuleResolutionHost, ModuleResolutionKind, ModuleSpecifierCache, + ModuleSpecifierEnding, ModuleSpecifierOptions, ModuleSpecifierResolutionHost, NodeFlags, @@ -89,6 +89,7 @@ import { ResolutionMode, resolvePath, ScriptKind, + shouldAllowImportingTsExtension, some, SourceFile, startsWith, @@ -107,61 +108,63 @@ import { // Used by importFixes, getEditsForFileRename, and declaration emit to synthesize import module specifiers. const enum RelativePreference { Relative, NonRelative, Shortest, ExternalNonRelative } -// See UserPreferences#importPathEnding -const enum Ending { Minimal, Index, JsExtension } // Processed preferences interface Preferences { readonly relativePreference: RelativePreference; - readonly ending: Ending; + /** + * @param syntaxImpliedNodeFormat Used when the import syntax implies ESM or CJS irrespective of the mode of the file. + */ + getAllowedEndingsInPrefererredOrder(syntaxImpliedNodeFormat?: SourceFile["impliedNodeFormat"]): ModuleSpecifierEnding[]; } -function getPreferences(host: ModuleSpecifierResolutionHost, { importModuleSpecifierPreference, importModuleSpecifierEnding }: UserPreferences, compilerOptions: CompilerOptions, importingSourceFile: SourceFile): Preferences { +function getPreferences( + { importModuleSpecifierPreference, importModuleSpecifierEnding }: UserPreferences, + compilerOptions: CompilerOptions, + importingSourceFile: SourceFile, + oldImportSpecifier?: string, +): Preferences { + const preferredEnding = getPreferredEnding(); return { relativePreference: + oldImportSpecifier !== undefined ? (isExternalModuleNameRelative(oldImportSpecifier) ? + RelativePreference.Relative : + RelativePreference.NonRelative) : importModuleSpecifierPreference === "relative" ? RelativePreference.Relative : importModuleSpecifierPreference === "non-relative" ? RelativePreference.NonRelative : importModuleSpecifierPreference === "project-relative" ? RelativePreference.ExternalNonRelative : RelativePreference.Shortest, - ending: getEnding(), + getAllowedEndingsInPrefererredOrder: syntaxImpliedNodeFormat => { + if (syntaxImpliedNodeFormat === ModuleKind.ESNext || (syntaxImpliedNodeFormat ?? importingSourceFile.impliedNodeFormat) === ModuleKind.ESNext) { + if (shouldAllowImportingTsExtension(compilerOptions, importingSourceFile.fileName)) { + return [ModuleSpecifierEnding.TsExtension, ModuleSpecifierEnding.JsExtension]; + } + return [ModuleSpecifierEnding.JsExtension]; + } + if (getEmitModuleResolutionKind(compilerOptions) === ModuleResolutionKind.Classic) { + return [ModuleSpecifierEnding.Index, ModuleSpecifierEnding.JsExtension]; + } + switch (preferredEnding) { + case ModuleSpecifierEnding.JsExtension: return [ModuleSpecifierEnding.JsExtension, ModuleSpecifierEnding.Minimal, ModuleSpecifierEnding.Index]; + case ModuleSpecifierEnding.TsExtension: return [ModuleSpecifierEnding.TsExtension, ModuleSpecifierEnding.TsExtension, ModuleSpecifierEnding.Minimal, ModuleSpecifierEnding.Index]; + case ModuleSpecifierEnding.Index: return [ModuleSpecifierEnding.Index, ModuleSpecifierEnding.Minimal, ModuleSpecifierEnding.JsExtension]; + case ModuleSpecifierEnding.Minimal: return [ModuleSpecifierEnding.Minimal, ModuleSpecifierEnding.Index, ModuleSpecifierEnding.JsExtension]; + default: Debug.assertNever(preferredEnding); + } + }, }; - function getEnding(): Ending { - switch (importModuleSpecifierEnding) { - case "minimal": return Ending.Minimal; - case "index": return Ending.Index; - case "js": return Ending.JsExtension; - default: return usesJsExtensionOnImports(importingSourceFile) || isFormatRequiringExtensions(compilerOptions, importingSourceFile.path, host) ? Ending.JsExtension - : getEmitModuleResolutionKind(compilerOptions) !== ModuleResolutionKind.NodeJs ? Ending.Index : Ending.Minimal; - } - } -} -function getPreferencesForUpdate(compilerOptions: CompilerOptions, oldImportSpecifier: string, importingSourceFileName: Path, host: ModuleSpecifierResolutionHost): Preferences { - return { - relativePreference: isExternalModuleNameRelative(oldImportSpecifier) ? RelativePreference.Relative : RelativePreference.NonRelative, - ending: hasJSFileExtension(oldImportSpecifier) || isFormatRequiringExtensions(compilerOptions, importingSourceFileName, host) ? - Ending.JsExtension : - getEmitModuleResolutionKind(compilerOptions) !== ModuleResolutionKind.NodeJs || endsWith(oldImportSpecifier, "index") ? Ending.Index : Ending.Minimal, - }; -} - -function isFormatRequiringExtensions(compilerOptions: CompilerOptions, importingSourceFileName: Path, host: ModuleSpecifierResolutionHost) { - if (getEmitModuleResolutionKind(compilerOptions) !== ModuleResolutionKind.Node16 - && getEmitModuleResolutionKind(compilerOptions) !== ModuleResolutionKind.NodeNext) { - return false; + function getPreferredEnding(): ModuleSpecifierEnding { + if (oldImportSpecifier !== undefined) { + if (hasJSFileExtension(oldImportSpecifier)) return ModuleSpecifierEnding.JsExtension; + if (endsWith(oldImportSpecifier, "/index")) return ModuleSpecifierEnding.Index; + } + return getModuleSpecifierEndingPreference( + importModuleSpecifierEnding, + importingSourceFile.impliedNodeFormat, + compilerOptions, + importingSourceFile); } - return getImpliedNodeFormatForFile(importingSourceFileName, host.getPackageJsonInfoCache?.(), getModuleResolutionHost(host), compilerOptions) !== ModuleKind.CommonJS; -} - -function getModuleResolutionHost(host: ModuleSpecifierResolutionHost): ModuleResolutionHost { - return { - fileExists: host.fileExists, - readFile: Debug.checkDefined(host.readFile), - directoryExists: host.directoryExists, - getCurrentDirectory: host.getCurrentDirectory, - realpath: host.realpath, - useCaseSensitiveFileNames: host.useCaseSensitiveFileNames?.(), - }; } // `importingSourceFile` and `importingSourceFileName`? Why not just use `importingSourceFile.path`? @@ -178,7 +181,7 @@ export function updateModuleSpecifier( oldImportSpecifier: string, options: ModuleSpecifierOptions = {}, ): string | undefined { - const res = getModuleSpecifierWorker(compilerOptions, importingSourceFile, importingSourceFileName, toFileName, host, getPreferencesForUpdate(compilerOptions, oldImportSpecifier, importingSourceFileName, host), {}, options); + const res = getModuleSpecifierWorker(compilerOptions, importingSourceFile, importingSourceFileName, toFileName, host, getPreferences({}, compilerOptions, importingSourceFile, oldImportSpecifier), {}, options); if (res === oldImportSpecifier) return undefined; return res; } @@ -198,7 +201,7 @@ export function getModuleSpecifier( host: ModuleSpecifierResolutionHost, options: ModuleSpecifierOptions = {}, ): string { - return getModuleSpecifierWorker(compilerOptions, importingSourceFile, importingSourceFileName, toFileName, host, getPreferences(host, {}, compilerOptions, importingSourceFile), {}, options); + return getModuleSpecifierWorker(compilerOptions, importingSourceFile, importingSourceFileName, toFileName, host, getPreferences({}, compilerOptions, importingSourceFile), {}, options); } /** @internal */ @@ -331,7 +334,7 @@ function computeModuleSpecifiers( options: ModuleSpecifierOptions = {}, ): readonly string[] { const info = getInfo(importingSourceFile.path, host); - const preferences = getPreferences(host, userPreferences, compilerOptions, importingSourceFile); + const preferences = getPreferences(userPreferences, compilerOptions, importingSourceFile); const existingSpecifier = forEach(modulePaths, modulePath => forEach( host.getFileIncludeReasons().get(toPath(modulePath.path, host.getCurrentDirectory(), info.getCanonicalFileName)), reason => { @@ -423,17 +426,19 @@ function getInfo(importingSourceFileName: Path, host: ModuleSpecifierResolutionH return { getCanonicalFileName, importingSourceFileName, sourceDirectory }; } -function getLocalModuleSpecifier(moduleFileName: string, info: Info, compilerOptions: CompilerOptions, host: ModuleSpecifierResolutionHost, importMode: ResolutionMode, { ending, relativePreference }: Preferences): string; -function getLocalModuleSpecifier(moduleFileName: string, info: Info, compilerOptions: CompilerOptions, host: ModuleSpecifierResolutionHost, importMode: ResolutionMode, { ending, relativePreference }: Preferences, pathsOnly?: boolean): string | undefined; -function getLocalModuleSpecifier(moduleFileName: string, info: Info, compilerOptions: CompilerOptions, host: ModuleSpecifierResolutionHost, importMode: ResolutionMode, { ending, relativePreference }: Preferences, pathsOnly?: boolean): string | undefined { +function getLocalModuleSpecifier(moduleFileName: string, info: Info, compilerOptions: CompilerOptions, host: ModuleSpecifierResolutionHost, importMode: ResolutionMode, preferences: Preferences): string; +function getLocalModuleSpecifier(moduleFileName: string, info: Info, compilerOptions: CompilerOptions, host: ModuleSpecifierResolutionHost, importMode: ResolutionMode, preferences: Preferences, pathsOnly?: boolean): string | undefined; +function getLocalModuleSpecifier(moduleFileName: string, info: Info, compilerOptions: CompilerOptions, host: ModuleSpecifierResolutionHost, importMode: ResolutionMode, { getAllowedEndingsInPrefererredOrder, relativePreference }: Preferences, pathsOnly?: boolean): string | undefined { const { baseUrl, paths, rootDirs } = compilerOptions; if (pathsOnly && !paths) { return undefined; } const { sourceDirectory, getCanonicalFileName } = info; + const allowedEndings = getAllowedEndingsInPrefererredOrder(importMode); + const ending = allowedEndings[0]; const relativePath = rootDirs && tryGetModuleNameFromRootDirs(rootDirs, moduleFileName, sourceDirectory, getCanonicalFileName, ending, compilerOptions) || - removeExtensionAndIndexPostFix(ensurePathIsNonModuleName(getRelativePathFromDirectory(sourceDirectory, moduleFileName, getCanonicalFileName)), ending, compilerOptions); + processEnding(ensurePathIsNonModuleName(getRelativePathFromDirectory(sourceDirectory, moduleFileName, getCanonicalFileName)), ending, compilerOptions); if (!baseUrl && !paths || relativePreference === RelativePreference.Relative) { return pathsOnly ? undefined : relativePath; } @@ -444,12 +449,12 @@ function getLocalModuleSpecifier(moduleFileName: string, info: Info, compilerOpt return pathsOnly ? undefined : relativePath; } - const fromPaths = paths && tryGetModuleNameFromPaths(relativeToBaseUrl, paths, getAllowedEndings(ending, compilerOptions, importMode), host, compilerOptions); + const fromPaths = paths && tryGetModuleNameFromPaths(relativeToBaseUrl, paths, allowedEndings, host, compilerOptions); if (pathsOnly) { return fromPaths; } - const maybeNonRelative = fromPaths === undefined && baseUrl !== undefined ? removeExtensionAndIndexPostFix(relativeToBaseUrl, ending, compilerOptions) : fromPaths; + const maybeNonRelative = fromPaths === undefined && baseUrl !== undefined ? processEnding(relativeToBaseUrl, ending, compilerOptions) : fromPaths; if (!maybeNonRelative) { return relativePath; } @@ -508,10 +513,6 @@ export function countPathComponents(path: string): number { return count; } -function usesJsExtensionOnImports({ imports }: SourceFile): boolean { - return firstDefined(imports, ({ text }) => pathIsRelative(text) ? hasJSFileExtension(text) : undefined) || false; -} - function comparePathsByRedirectAndNumberOfDirectorySeparators(a: ModulePath, b: ModulePath) { return compareBooleans(b.isRedirect, a.isRedirect) || compareNumberOfDirectorySeparators(a.path, b.path); } @@ -698,19 +699,7 @@ function tryGetModuleNameFromAmbientModule(moduleSymbol: Symbol, checker: TypeCh } } -function getAllowedEndings(preferredEnding: Ending, compilerOptions: CompilerOptions, importMode: ResolutionMode) { - if (getEmitModuleResolutionKind(compilerOptions) >= ModuleResolutionKind.Node16 && importMode === ModuleKind.ESNext) { - return [Ending.JsExtension]; - } - switch (preferredEnding) { - case Ending.JsExtension: return [Ending.JsExtension, Ending.Minimal, Ending.Index]; - case Ending.Index: return [Ending.Index, Ending.Minimal, Ending.JsExtension]; - case Ending.Minimal: return [Ending.Minimal, Ending.Index, Ending.JsExtension]; - default: Debug.assertNever(preferredEnding); - } -} - -function tryGetModuleNameFromPaths(relativeToBaseUrl: string, paths: MapLike, allowedEndings: Ending[], host: ModuleSpecifierResolutionHost, compilerOptions: CompilerOptions): string | undefined { +function tryGetModuleNameFromPaths(relativeToBaseUrl: string, paths: MapLike, allowedEndings: ModuleSpecifierEnding[], host: ModuleSpecifierResolutionHost, compilerOptions: CompilerOptions): string | undefined { for (const key in paths) { for (const patternText of paths[key]) { const pattern = normalizePath(patternText); @@ -751,9 +740,9 @@ function tryGetModuleNameFromPaths(relativeToBaseUrl: string, paths: MapLike ({ + const candidates: { ending: ModuleSpecifierEnding | undefined, value: string }[] = allowedEndings.map(ending => ({ ending, - value: removeExtensionAndIndexPostFix(relativeToBaseUrl, ending, compilerOptions) + value: processEnding(relativeToBaseUrl, ending, compilerOptions) })); if (tryGetExtensionFromPath(pattern)) { candidates.push({ ending: undefined, value: relativeToBaseUrl }); @@ -774,23 +763,23 @@ function tryGetModuleNameFromPaths(relativeToBaseUrl: string, paths: MapLike c.ending !== Ending.Minimal && pattern === c.value) || - some(candidates, c => c.ending === Ending.Minimal && pattern === c.value && validateEnding(c)) + some(candidates, c => c.ending !== ModuleSpecifierEnding.Minimal && pattern === c.value) || + some(candidates, c => c.ending === ModuleSpecifierEnding.Minimal && pattern === c.value && validateEnding(c)) ) { return key; } } } - function validateEnding({ ending, value }: { ending: Ending | undefined, value: string }) { + function validateEnding({ ending, value }: { ending: ModuleSpecifierEnding | undefined, value: string }) { // Optimization: `removeExtensionAndIndexPostFix` can query the file system (a good bit) if `ending` is `Minimal`, the basename // is 'index', and a `host` is provided. To avoid that until it's unavoidable, we ran the function with no `host` above. Only // here, after we've checked that the minimal ending is indeed a match (via the length and prefix/suffix checks / `some` calls), // do we check that the host-validated result is consistent with the answer we got before. If it's not, it falls back to the - // `Ending.Index` result, which should already be in the list of candidates if `Minimal` was. (Note: the assumption here is + // `ModuleSpecifierEnding.Index` result, which should already be in the list of candidates if `Minimal` was. (Note: the assumption here is // that every module resolution mode that supports dropping extensions also supports dropping `/index`. Like literally // everything else in this file, this logic needs to be updated if that's not true in some future module resolution mode.) - return ending !== Ending.Minimal || value === removeExtensionAndIndexPostFix(relativeToBaseUrl, ending, compilerOptions, host); + return ending !== ModuleSpecifierEnding.Minimal || value === processEnding(relativeToBaseUrl, ending, compilerOptions, host); } } @@ -865,7 +854,7 @@ function tryGetModuleNameFromExports(options: CompilerOptions, targetFilePath: s return undefined; } -function tryGetModuleNameFromRootDirs(rootDirs: readonly string[], moduleFileName: string, sourceDirectory: string, getCanonicalFileName: (file: string) => string, ending: Ending, compilerOptions: CompilerOptions): string | undefined { +function tryGetModuleNameFromRootDirs(rootDirs: readonly string[], moduleFileName: string, sourceDirectory: string, getCanonicalFileName: (file: string) => string, ending: ModuleSpecifierEnding, compilerOptions: CompilerOptions): string | undefined { const normalizedTargetPaths = getPathsRelativeToRootDirs(moduleFileName, rootDirs, getCanonicalFileName); if (normalizedTargetPaths === undefined) { return undefined; @@ -879,10 +868,7 @@ function tryGetModuleNameFromRootDirs(rootDirs: readonly string[], moduleFileNam if (!shortest) { return undefined; } - - return getEmitModuleResolutionKind(compilerOptions) === ModuleResolutionKind.Classic - ? removeFileExtension(shortest) - : removeExtensionAndIndexPostFix(shortest, ending, compilerOptions); + return processEnding(shortest, ending, compilerOptions); } function tryGetModuleNameAsNodeModule({ path, isRedirect }: ModulePath, { getCanonicalFileName, sourceDirectory }: Info, importingSourceFile: SourceFile, host: ModuleSpecifierResolutionHost, options: CompilerOptions, userPreferences: UserPreferences, packageNameOnly?: boolean, overrideMode?: ResolutionMode): string | undefined { @@ -896,7 +882,8 @@ function tryGetModuleNameAsNodeModule({ path, isRedirect }: ModulePath, { getCan // Simplify the full file path to something that can be resolved by Node. - const preferences = getPreferences(host, userPreferences, options, importingSourceFile); + const preferences = getPreferences(userPreferences, options, importingSourceFile); + const allowedEndings = preferences.getAllowedEndingsInPrefererredOrder(); let moduleSpecifier = path; let isPackageRootPath = false; if (!packageNameOnly) { @@ -923,7 +910,7 @@ function tryGetModuleNameAsNodeModule({ path, isRedirect }: ModulePath, { getCan // try with next level of directory packageRootIndex = path.indexOf(directorySeparator, packageRootIndex + 1); if (packageRootIndex === -1) { - moduleSpecifier = removeExtensionAndIndexPostFix(moduleFileName, preferences.ending, options, host); + moduleSpecifier = processEnding(moduleFileName, allowedEndings[0], options, host); break; } } @@ -979,7 +966,7 @@ function tryGetModuleNameAsNodeModule({ path, isRedirect }: ModulePath, { getCan const fromPaths = tryGetModuleNameFromPaths( subModuleName, versionPaths.paths, - getAllowedEndings(preferences.ending, options, importMode), + allowedEndings, host, options ); @@ -1036,13 +1023,13 @@ function getPathsRelativeToRootDirs(path: string, rootDirs: readonly string[], g }); } -function removeExtensionAndIndexPostFix(fileName: string, ending: Ending, options: CompilerOptions, host?: ModuleSpecifierResolutionHost): string { +function processEnding(fileName: string, ending: ModuleSpecifierEnding, options: CompilerOptions, host?: ModuleSpecifierResolutionHost): string { if (fileExtensionIsOneOf(fileName, [Extension.Json, Extension.Mjs, Extension.Cjs])) return fileName; const noExtension = removeFileExtension(fileName); if (fileName === noExtension) return fileName; if (fileExtensionIsOneOf(fileName, [Extension.Dmts, Extension.Mts, Extension.Dcts, Extension.Cts])) return noExtension + getJSExtensionForFile(fileName, options); switch (ending) { - case Ending.Minimal: + case ModuleSpecifierEnding.Minimal: const withoutIndex = removeSuffix(noExtension, "/index"); if (host && withoutIndex !== noExtension && tryGetAnyFileFromPath(host, withoutIndex)) { // Can't remove index if there's a file by the same name as the directory. @@ -1050,10 +1037,12 @@ function removeExtensionAndIndexPostFix(fileName: string, ending: Ending, option return noExtension; } return withoutIndex; - case Ending.Index: + case ModuleSpecifierEnding.Index: return noExtension; - case Ending.JsExtension: + case ModuleSpecifierEnding.JsExtension: return noExtension + getJSExtensionForFile(fileName, options); + case ModuleSpecifierEnding.TsExtension: + return fileName; default: return Debug.assertNever(ending); } diff --git a/src/compiler/program.ts b/src/compiler/program.ts index 5cefcfdda324f..d94b2bc1a6503 100644 --- a/src/compiler/program.ts +++ b/src/compiler/program.ts @@ -215,6 +215,7 @@ import { ModuleResolutionHost, moduleResolutionIsEqualTo, ModuleResolutionKind, + moduleResolutionSupportsResolvingTsExtensions, Mutable, Node, NodeArray, @@ -4205,6 +4206,10 @@ export function createProgram(rootNamesOrOptions: readonly string[] | CreateProg createOptionValueDiagnostic("importsNotUsedAsValues", Diagnostics.Option_preserveValueImports_can_only_be_used_when_module_is_set_to_es2015_or_later); } + if (options.allowImportingTsExtensions && !(moduleResolutionSupportsResolvingTsExtensions(options) && (options.noEmit || options.emitDeclarationOnly))) { + createOptionValueDiagnostic("allowImportingTsExtensions", Diagnostics.Option_allowImportingTsExtensions_can_only_be_used_when_moduleResolution_is_set_to_minimal_and_either_noEmit_or_emitDeclarationOnly_is_set); + } + // If the emit is enabled make sure that every output file is unique and not overwriting any of the input files if (!options.noEmit && !options.suppressOutputPathCheck) { const emitHost = getEmitHost(); diff --git a/src/compiler/types.ts b/src/compiler/types.ts index 773d64df907ae..7f806f860a1a8 100644 --- a/src/compiler/types.ts +++ b/src/compiler/types.ts @@ -6945,6 +6945,7 @@ export type CompilerOptionsValue = string | number | boolean | (string | number) export interface CompilerOptions { /** @internal */ all?: boolean; + allowImportingTsExtensions?: boolean; allowJs?: boolean; /** @internal */ allowNonTsExtensions?: boolean; allowSyntheticDefaultImports?: boolean; @@ -7501,6 +7502,11 @@ export interface ResolvedModule { resolvedFileName: string; /** True if `resolvedFileName` comes from `node_modules`. */ isExternalLibraryImport?: boolean; + /** + * True if the original module reference used a .ts extension to refer directly to a .ts file, + * which should produce an error during checking if emit is enabled. + */ + resolvedUsingTsExtension: boolean; } /** diff --git a/src/compiler/utilities.ts b/src/compiler/utilities.ts index 5c65c652db3ef..a3df7748bca42 100644 --- a/src/compiler/utilities.ts +++ b/src/compiler/utilities.ts @@ -11,6 +11,7 @@ import { AnyImportOrReExport, AnyImportSyntax, AnyValidImportOrReExport, + append, arrayFrom, ArrayLiteralExpression, ArrayTypeNode, @@ -441,6 +442,7 @@ import { semanticDiagnosticsOptionDeclarations, SetAccessorDeclaration, ShorthandPropertyAssignment, + shouldAllowImportingTsExtension, Signature, SignatureDeclaration, SignatureFlags, @@ -513,6 +515,7 @@ import { TypeReferenceNode, unescapeLeadingUnderscores, UnionOrIntersectionTypeNode, + UserPreferences, ValidImportTypeNode, VariableDeclaration, VariableDeclarationInitializedTo, @@ -7693,6 +7696,10 @@ export function getEmitModuleKind(compilerOptions: {module?: CompilerOptions["mo getEmitScriptTarget(compilerOptions) >= ScriptTarget.ES2015 ? ModuleKind.ES2015 : ModuleKind.CommonJS; } +export function emitModuleKindIsNonNodeESM(moduleKind: ModuleKind) { + return moduleKind >= ModuleKind.ES2015 && moduleKind <= ModuleKind.ESNext; +} + /** @internal */ export function getEmitModuleResolutionKind(compilerOptions: CompilerOptions) { let moduleResolution = compilerOptions.moduleResolution; @@ -8430,6 +8437,92 @@ export function hasTSFileExtension(fileName: string): boolean { return some(supportedTSExtensionsFlat, extension => fileExtensionIs(fileName, extension)); } +/** + * @internal + * Corresponds to UserPreferences#importPathEnding + */ +export const enum ModuleSpecifierEnding { + Minimal, + Index, + JsExtension, + TsExtension, +} + +/** @internal */ +export function usesExtensionsOnImports({ imports }: SourceFile, hasExtension: (text: string) => boolean = or(hasJSFileExtension, hasTSFileExtension)): boolean { + return firstDefined(imports, ({ text }) => pathIsRelative(text) ? hasExtension(text) : undefined) || false; +} + +/** @internal */ +export function getModuleSpecifierEndingPreference(preference: UserPreferences["importModuleSpecifierEnding"], resolutionMode: ResolutionMode, compilerOptions: CompilerOptions, sourceFile: SourceFile): ModuleSpecifierEnding { + if (preference === "js" || resolutionMode === ModuleKind.ESNext) { + // Extensions are explicitly requested or required. Now choose between .js and .ts. + if (!shouldAllowImportingTsExtension(compilerOptions)) { + return ModuleSpecifierEnding.JsExtension; + } + // `allowImportingTsExtensions` is a strong signal, so use .ts unless the file + // already uses .js extensions and no .ts extensions. + return inferPreference() !== ModuleSpecifierEnding.JsExtension + ? ModuleSpecifierEnding.TsExtension + : ModuleSpecifierEnding.JsExtension; + } + if (preference === "minimal") { + return ModuleSpecifierEnding.Minimal; + } + if (preference === "index") { + return ModuleSpecifierEnding.Index; + } + + // No preference was specified. + // Look at imports and/or requires to guess whether .js, .ts, or extensionless imports are preferred. + // N.B. that `Index` detection is not supported since it would require file system probing to do + // accurately, and more importantly, literally nobody wants `Index` and its existence is a mystery. + if (!shouldAllowImportingTsExtension(compilerOptions)) { + // If .ts imports are not valid, we only need to see one .js import to go with that. + return usesExtensionsOnImports(sourceFile) ? ModuleSpecifierEnding.JsExtension : ModuleSpecifierEnding.Minimal; + } + + return inferPreference(); + + function inferPreference() { + let usesJsExtensions = false; + const specifiers = sourceFile.imports.length ? sourceFile.imports.map(i => i.text) : + isSourceFileJS(sourceFile) ? getRequiresAtTopOfFile(sourceFile).map(r => r.arguments[0].text) : + emptyArray; + for (const specifier of specifiers) { + if (pathIsRelative(specifier)) { + if (hasTSFileExtension(specifier)) { + return ModuleSpecifierEnding.TsExtension; + } + if (hasJSFileExtension(specifier)) { + usesJsExtensions = true; + } + } + } + return usesJsExtensions ? ModuleSpecifierEnding.JsExtension : ModuleSpecifierEnding.Minimal; + } +} + +function getRequiresAtTopOfFile(sourceFile: SourceFile): readonly RequireOrImportCall[] { + let nonRequireStatementCount = 0; + let requires: RequireOrImportCall[] | undefined; + for (const statement of sourceFile.statements) { + if (nonRequireStatementCount > 3) { + break; + } + if (isRequireVariableStatement(statement)) { + requires = concatenate(requires, statement.declarationList.declarations.map(d => d.initializer)); + } + else if (isExpressionStatement(statement) && isRequireCall(statement.expression, /*requireStringLiteralLikeArgument*/ true)) { + requires = append(requires, statement.expression); + } + else { + nonRequireStatementCount++; + } + } + return requires || emptyArray; +} + /** @internal */ export function isSupportedSourceFileName(fileName: string, compilerOptions?: CompilerOptions, extraFileExtensions?: readonly FileExtensionInfo[]) { if (!fileName) return false; diff --git a/src/harness/fourslashImpl.ts b/src/harness/fourslashImpl.ts index ac7c0c40ad3b5..8a2a4c65390d5 100644 --- a/src/harness/fourslashImpl.ts +++ b/src/harness/fourslashImpl.ts @@ -804,18 +804,6 @@ export class TestState { }); } - private renderMarkers(markers: { text: string, fileName: string, position: number }[]) { - const filesToDisplay = ts.deduplicate(markers.map(m => m.fileName), ts.equateValues); - return filesToDisplay.map(fileName => { - const markersToRender = markers.filter(m => m.fileName === fileName).sort((a, b) => b.position - a.position); - let fileContent = this.tryGetFileContent(fileName) || ""; - for (const marker of markersToRender) { - fileContent = fileContent.slice(0, marker.position) + `\x1b[1;4m/*${marker.text}*/\x1b[0;31m` + fileContent.slice(marker.position); - } - return `// @Filename: ${fileName}\n${fileContent}`; - }).join("\n\n"); - } - private verifyDefinitionTextSpan(defs: ts.DefinitionInfoAndBoundSpan, startMarkerName: string) { const range = this.testData.ranges.find(range => this.markerName(range.marker!) === startMarkerName); @@ -839,6 +827,22 @@ export class TestState { } } + private renderMarkers(markers: { text: string, fileName: string, position: number }[], useTerminalBoldSequence = true) { + const filesToDisplay = ts.deduplicate(markers.map(m => m.fileName), ts.equateValues); + return filesToDisplay.map(fileName => { + const markersToRender = markers.filter(m => m.fileName === fileName).sort((a, b) => b.position - a.position); + let fileContent = this.tryGetFileContent(fileName) || ""; + for (const marker of markersToRender) { + fileContent = fileContent.slice(0, marker.position) + bold(`/*${marker.text}*/`) + fileContent.slice(marker.position); + } + return `// @Filename: ${fileName}\n${fileContent}`; + }).join("\n\n"); + + function bold(text: string) { + return useTerminalBoldSequence ? `\x1b[1;4m${text}\x1b[0;31m` : text; + } + } + public verifyGetEmitOutputForCurrentFile(expected: string): void { const emit = this.languageService.getEmitOutput(this.activeFile.fileName); if (emit.outputFiles.length !== 1) { @@ -3188,12 +3192,53 @@ export class TestState { if (!negative && !validBraceCompletion) { this.raiseError(`${position} is not a valid brace completion position for ${openingBrace}`); } - if (negative && validBraceCompletion) { this.raiseError(`${position} is a valid brace completion position for ${openingBrace}`); } } + public baselineAutoImports(markerName: string, preferences?: ts.UserPreferences) { + const marker = this.getMarkerByName(markerName); + const baselineFile = this.getBaselineFileNameForContainingTestFile(`.baseline.md`); + const completionPreferences = { + includeCompletionsForModuleExports: true, + includeCompletionsWithInsertText: true, + allowIncompleteCompletions: true, + includeCompletionsWithSnippetText: true, + ...preferences + }; + + const ext = ts.getAnyExtensionFromPath(this.activeFile.fileName).slice(1); + const lang = ["mts", "cts"].includes(ext) ? "ts" : ext; + let baselineText = codeFence(this.renderMarkers([{ text: "|", fileName: marker.fileName, position: marker.position }], /*useTerminalBoldSequence*/ false), lang) + "\n\n"; + this.goToMarker(marker); + + const completions = this.getCompletionListAtCaret(completionPreferences)!; + + const autoImportCompletions = completions.entries.filter(c => c.hasAction && c.source && c.sortText === ts.Completions.SortText.AutoImportSuggestions); + if (autoImportCompletions.length) { + baselineText += `## From completions\n\n${autoImportCompletions.map(c => `- \`${c.name}\` from \`"${c.source}"\``).join("\n")}\n\n`; + autoImportCompletions.forEach(c => { + const details = this.getCompletionEntryDetails(c.name, c.source, c.data, completionPreferences); + assert(details?.codeActions, `Entry '${c.name}' from "${c.source}" returned no code actions from completion details request`); + assert(details.codeActions.length === 1, `Entry '${c.name}' from "${c.source}" returned more than one code action`); + assert(details.codeActions[0].changes.length === 1, `Entry '${c.name}' from "${c.source}" returned a code action changing more than one file`); + assert(details.codeActions[0].changes[0].fileName === this.activeFile.fileName, `Entry '${c.name}' from "${c.source}" returned a code action changing a different file`); + const changes = details.codeActions[0].changes[0].textChanges; + const completionChange: ts.TextChange = { newText: c.insertText || c.name, span: c.replacementSpan || completions.optionalReplacementSpan || { start: marker.position, length: 0 } }; + const sortedChanges = [...changes, completionChange].sort((a, b) => a.span.start - b.span.start); + let newFileContent = this.activeFile.content; + for (let i = sortedChanges.length - 1; i >= 0; i--) { + newFileContent = newFileContent.substring(0, sortedChanges[i].span.start) + sortedChanges[i].newText + newFileContent.substring(sortedChanges[i].span.start + sortedChanges[i].span.length); + } + baselineText += codeFence(newFileContent, lang) + "\n\n"; + }); + } + + // TODO: do codefixes too + Harness.Baseline.runBaseline(baselineFile, baselineText); + } + public verifyJsxClosingTag(map: { [markerName: string]: ts.JsxClosingTagInfo | undefined }): void { for (const markerName in map) { this.goToMarker(markerName); @@ -4605,10 +4650,10 @@ function rangesOfDiffBetweenTwoStrings(source: string, target: string) { else { ranges.push({ start: index - 1, length: 1 }); } - } - else { + } + else { ranges.push({ start: index - 1, length: 1 }); - } + } }; for (let index = 0; index < Math.max(source.length, target.length); index++) { @@ -4618,10 +4663,10 @@ function rangesOfDiffBetweenTwoStrings(source: string, target: string) { } return ranges; - } +} - // Adds an _ when the source string and the target string have a whitespace difference - function highlightDifferenceBetweenStrings(source: string, target: string) { +// Adds an _ when the source string and the target string have a whitespace difference +function highlightDifferenceBetweenStrings(source: string, target: string) { const ranges = rangesOfDiffBetweenTwoStrings(source, target); let emTarget = target; ranges.forEach((range, index) => { @@ -4633,8 +4678,12 @@ function rangesOfDiffBetweenTwoStrings(source: string, target: string) { range.start + 1 + additionalOffset, range.start + range.length + 1 + additionalOffset ); - const after = emTarget.slice(range.start + range.length + 1 + additionalOffset, emTarget.length); + const after = emTarget.slice(range.start + range.length + 1 + additionalOffset, emTarget.length); emTarget = before + lhs + between + rhs + after; }); return emTarget; - } +} + +function codeFence(code: string, lang?: string) { + return `\`\`\`${lang || ""}\n${code}\n\`\`\``; +} diff --git a/src/harness/fourslashInterfaceImpl.ts b/src/harness/fourslashInterfaceImpl.ts index af1044d6fee6b..b71d67b530d8a 100644 --- a/src/harness/fourslashInterfaceImpl.ts +++ b/src/harness/fourslashInterfaceImpl.ts @@ -488,6 +488,10 @@ export class Verify extends VerifyNegatable { this.state.verifyImportFixModuleSpecifiers(marker, moduleSpecifiers, preferences); } + public baselineAutoImports(marker: string, preferences?: ts.UserPreferences) { + this.state.baselineAutoImports(marker, preferences); + } + public navigationBar(json: any, options?: { checkSpans?: boolean }) { this.state.verifyNavigationBar(json, options); } diff --git a/src/services/codefixes/importFixes.ts b/src/services/codefixes/importFixes.ts index 2f0c90737549a..1b753b8942dde 100644 --- a/src/services/codefixes/importFixes.ts +++ b/src/services/codefixes/importFixes.ts @@ -814,7 +814,8 @@ function getNewImportFixes( const compilerOptions = program.getCompilerOptions(); const moduleSpecifierResolutionHost = createModuleSpecifierResolutionHost(program, host); const getChecker = createGetChecker(program, host); - const rejectNodeModulesRelativePaths = moduleResolutionUsesNodeModules(getEmitModuleResolutionKind(compilerOptions)); + const moduleResolution = getEmitModuleResolutionKind(compilerOptions); + const rejectNodeModulesRelativePaths = moduleResolutionUsesNodeModules(moduleResolution); const getModuleSpecifiers = fromCacheOnly ? (moduleSymbol: Symbol) => ({ moduleSpecifiers: moduleSpecifiers.tryGetModuleSpecifiersFromCache(moduleSymbol, sourceFile, moduleSpecifierResolutionHost, preferences), computedWithoutCache: false }) : (moduleSymbol: Symbol, checker: TypeChecker) => moduleSpecifiers.getModuleSpecifiersWithCacheInfo(moduleSymbol, checker, compilerOptions, sourceFile, moduleSpecifierResolutionHost, preferences); diff --git a/src/services/completions.ts b/src/services/completions.ts index 6e02fd1e567a4..b5ee425d6732c 100644 --- a/src/services/completions.ts +++ b/src/services/completions.ts @@ -548,7 +548,7 @@ const enum KeywordCompletionFilters { const enum GlobalsSearch { Continue, Success, Fail } -interface ModuleSpecifierResolutioContext { +interface ModuleSpecifierResolutionContext { tryResolve: (exportInfo: readonly SymbolExportInfo[], symbolName: string, isFromAmbientModule: boolean) => ModuleSpecifierResolutionResult; resolvedAny: () => boolean; skippedAny: () => boolean; @@ -569,7 +569,7 @@ function resolvingModuleSpecifiers( preferences: UserPreferences, isForImportStatementCompletion: boolean, isValidTypeOnlyUseSite: boolean, - cb: (context: ModuleSpecifierResolutioContext) => TReturn, + cb: (context: ModuleSpecifierResolutionContext) => TReturn, ): TReturn { const start = timestamp(); // Under `--moduleResolution nodenext`, we have to resolve module specifiers up front, because diff --git a/src/services/shims.ts b/src/services/shims.ts index 6f36697703396..4339931349053 100644 --- a/src/services/shims.ts +++ b/src/services/shims.ts @@ -449,7 +449,7 @@ export class LanguageServiceShimHostAdapter implements LanguageServiceHost { const resolutionsInFile = JSON.parse(this.shimHost.getModuleResolutionsForFile!(containingFile)) as MapLike; // TODO: GH#18217 return map(moduleNames, name => { const result = getProperty(resolutionsInFile, name); - return result ? { resolvedFileName: result, extension: extensionFromPath(result), isExternalLibraryImport: false } : undefined; + return result ? { resolvedFileName: result, extension: extensionFromPath(result), isExternalLibraryImport: false, resolvedUsingTsExtension: false } : undefined; }); }; } diff --git a/src/services/stringCompletions.ts b/src/services/stringCompletions.ts index c319ad3b8e92a..42c1af1c7938c 100644 --- a/src/services/stringCompletions.ts +++ b/src/services/stringCompletions.ts @@ -49,6 +49,7 @@ import { getEmitModuleResolutionKind, getLeadingCommentRanges, getModeForUsageLocation, + getModuleSpecifierEndingPreference, getOwnKeys, getPackageJsonTypesVersionsPaths, getPathComponents, @@ -89,7 +90,9 @@ import { mapDefined, MapLike, ModuleKind, - ModuleResolutionKind, + moduleResolutionRespectsExports, + moduleResolutionUsesNodeModules, + ModuleSpecifierEnding, moduleSpecifiers, Node, normalizePath, @@ -120,6 +123,7 @@ import { StringLiteralLike, StringLiteralType, stripQuotes, + supportedTSImplementationExtensions, Symbol, SyntaxKind, textPart, @@ -525,49 +529,44 @@ function getStringLiteralCompletionsFromModuleNamesWorker(sourceFile: SourceFile const scriptPath = sourceFile.path; const scriptDirectory = getDirectoryPath(scriptPath); + const extensionOptions = getExtensionOptions(compilerOptions, ReferenceKind.ModuleSpecifier, sourceFile, preferences, mode); return isPathRelativeToScript(literalValue) || !compilerOptions.baseUrl && (isRootedDiskPath(literalValue) || isUrl(literalValue)) - ? getCompletionEntriesForRelativeModules(literalValue, scriptDirectory, compilerOptions, host, scriptPath, getIncludeExtensionOption()) - : getCompletionEntriesForNonRelativeModules(literalValue, scriptDirectory, mode, compilerOptions, host, getIncludeExtensionOption(), typeChecker); - - function getIncludeExtensionOption() { - const mode = isStringLiteralLike(node) ? getModeForUsageLocation(sourceFile, node) : undefined; - return preferences.importModuleSpecifierEnding === "js" || mode === ModuleKind.ESNext ? IncludeExtensionsOption.ModuleSpecifierCompletion : IncludeExtensionsOption.Exclude; - } + ? getCompletionEntriesForRelativeModules(literalValue, scriptDirectory, compilerOptions, host, scriptPath, extensionOptions) + : getCompletionEntriesForNonRelativeModules(literalValue, scriptDirectory, mode, compilerOptions, host, extensionOptions, typeChecker); } interface ExtensionOptions { - readonly extensions: readonly Extension[]; - readonly includeExtensionsOption: IncludeExtensionsOption; + readonly extensionsToSearch: readonly Extension[]; + readonly referenceKind: ReferenceKind; + readonly importingSourceFile: SourceFile; + readonly endingPreference?: UserPreferences["importModuleSpecifierEnding"]; + readonly resolutionMode?: ResolutionMode; } -function getExtensionOptions(compilerOptions: CompilerOptions, includeExtensionsOption = IncludeExtensionsOption.Exclude): ExtensionOptions { - return { extensions: flatten(getSupportedExtensionsForModuleResolution(compilerOptions)), includeExtensionsOption }; + +function getExtensionOptions(compilerOptions: CompilerOptions, referenceKind: ReferenceKind, importingSourceFile: SourceFile, preferences?: UserPreferences, resolutionMode?: ResolutionMode): ExtensionOptions { +return { + extensionsToSearch: flatten(getSupportedExtensionsForModuleResolution(compilerOptions)), + referenceKind, + importingSourceFile, + endingPreference: preferences?.importModuleSpecifierEnding, + resolutionMode, + }; } -function getCompletionEntriesForRelativeModules(literalValue: string, scriptDirectory: string, compilerOptions: CompilerOptions, host: LanguageServiceHost, scriptPath: Path, includeExtensions: IncludeExtensionsOption) { - const extensionOptions = getExtensionOptions(compilerOptions, includeExtensions); +function getCompletionEntriesForRelativeModules(literalValue: string, scriptDirectory: string, compilerOptions: CompilerOptions, host: LanguageServiceHost, scriptPath: Path, extensionOptions: ExtensionOptions) { if (compilerOptions.rootDirs) { return getCompletionEntriesForDirectoryFragmentWithRootDirs( compilerOptions.rootDirs, literalValue, scriptDirectory, extensionOptions, compilerOptions, host, scriptPath); } else { - return arrayFrom(getCompletionEntriesForDirectoryFragment(literalValue, scriptDirectory, extensionOptions, host, scriptPath).values()); + return arrayFrom(getCompletionEntriesForDirectoryFragment(literalValue, scriptDirectory, extensionOptions, host, /*moduleSpecifierIsRelative*/ false, scriptPath).values()); } } -function isEmitResolutionKindUsingNodeModules(compilerOptions: CompilerOptions): boolean { - return getEmitModuleResolutionKind(compilerOptions) === ModuleResolutionKind.NodeJs || - getEmitModuleResolutionKind(compilerOptions) === ModuleResolutionKind.Node16 || - getEmitModuleResolutionKind(compilerOptions) === ModuleResolutionKind.NodeNext; -} - -function isEmitModuleResolutionRespectingExportMaps(compilerOptions: CompilerOptions) { - return getEmitModuleResolutionKind(compilerOptions) === ModuleResolutionKind.Node16 || - getEmitModuleResolutionKind(compilerOptions) === ModuleResolutionKind.NodeNext; -} - function getSupportedExtensionsForModuleResolution(compilerOptions: CompilerOptions): readonly Extension[][] { const extensions = getSupportedExtensions(compilerOptions); - return isEmitResolutionKindUsingNodeModules(compilerOptions) ? + const moduleResolution = getEmitModuleResolutionKind(compilerOptions); + return moduleResolutionUsesNodeModules(moduleResolution) ? getSupportedExtensionsWithJsonIfResolveJsonModule(compilerOptions, extensions) : extensions; } @@ -595,22 +594,22 @@ function getCompletionEntriesForDirectoryFragmentWithRootDirs(rootDirs: string[] const basePath = compilerOptions.project || host.getCurrentDirectory(); const ignoreCase = !(host.useCaseSensitiveFileNames && host.useCaseSensitiveFileNames()); const baseDirectories = getBaseDirectoriesFromRootDirs(rootDirs, basePath, scriptDirectory, ignoreCase); - return flatMap(baseDirectories, baseDirectory => arrayFrom(getCompletionEntriesForDirectoryFragment(fragment, baseDirectory, extensionOptions, host, exclude).values())); + return flatMap(baseDirectories, baseDirectory => arrayFrom(getCompletionEntriesForDirectoryFragment(fragment, baseDirectory, extensionOptions, host, /*moduleSpecifierIsRelative*/ true, exclude).values())); } -const enum IncludeExtensionsOption { - Exclude, - Include, - ModuleSpecifierCompletion, +const enum ReferenceKind { + Filename, + ModuleSpecifier, } /** * Given a path ending at a directory, gets the completions for the path, and filters for those entries containing the basename. */ function getCompletionEntriesForDirectoryFragment( fragment: string, - scriptPath: string, + scriptDirectory: string, extensionOptions: ExtensionOptions, host: LanguageServiceHost, + moduleSpecifierIsRelative: boolean, exclude?: string, result = createNameAndKindSet() ): NameAndKindSet { @@ -634,23 +633,25 @@ function getCompletionEntriesForDirectoryFragment( fragment = ensureTrailingDirectorySeparator(fragment); - const absolutePath = resolvePath(scriptPath, fragment); + const absolutePath = resolvePath(scriptDirectory, fragment); const baseDirectory = hasTrailingDirectorySeparator(absolutePath) ? absolutePath : getDirectoryPath(absolutePath); - // check for a version redirect - const packageJsonPath = findPackageJson(baseDirectory, host); - if (packageJsonPath) { - const packageJson = readJson(packageJsonPath, host as { readFile: (filename: string) => string | undefined }); - const typesVersions = (packageJson as any).typesVersions; - if (typeof typesVersions === "object") { - const versionPaths = getPackageJsonTypesVersionsPaths(typesVersions)?.paths; - if (versionPaths) { - const packageDirectory = getDirectoryPath(packageJsonPath); - const pathInPackage = absolutePath.slice(ensureTrailingDirectorySeparator(packageDirectory).length); - if (addCompletionEntriesFromPaths(result, pathInPackage, packageDirectory, extensionOptions, host, versionPaths)) { - // A true result means one of the `versionPaths` was matched, which will block relative resolution - // to files and folders from here. All reachable paths given the pattern match are already added. - return result; + if (!moduleSpecifierIsRelative) { + // check for a version redirect + const packageJsonPath = findPackageJson(baseDirectory, host); + if (packageJsonPath) { + const packageJson = readJson(packageJsonPath, host as { readFile: (filename: string) => string | undefined }); + const typesVersions = (packageJson as any).typesVersions; + if (typeof typesVersions === "object") { + const versionPaths = getPackageJsonTypesVersionsPaths(typesVersions)?.paths; + if (versionPaths) { + const packageDirectory = getDirectoryPath(packageJsonPath); + const pathInPackage = absolutePath.slice(ensureTrailingDirectorySeparator(packageDirectory).length); + if (addCompletionEntriesFromPaths(result, pathInPackage, packageDirectory, extensionOptions, host, versionPaths)) { + // A true result means one of the `versionPaths` was matched, which will block relative resolution + // to files and folders from here. All reachable paths given the pattern match are already added. + return result; + } } } } @@ -660,16 +661,16 @@ function getCompletionEntriesForDirectoryFragment( if (!tryDirectoryExists(host, baseDirectory)) return result; // Enumerate the available files if possible - const files = tryReadDirectory(host, baseDirectory, extensionOptions.extensions, /*exclude*/ undefined, /*include*/ ["./*"]); + const files = tryReadDirectory(host, baseDirectory, extensionOptions.extensionsToSearch, /*exclude*/ undefined, /*include*/ ["./*"]); if (files) { for (let filePath of files) { filePath = normalizePath(filePath); - if (exclude && comparePaths(filePath, exclude, scriptPath, ignoreCase) === Comparison.EqualTo) { + if (exclude && comparePaths(filePath, exclude, scriptDirectory, ignoreCase) === Comparison.EqualTo) { continue; } - const { name, extension } = getFilenameWithExtensionOption(getBaseFileName(filePath), host.getCompilationSettings(), extensionOptions.includeExtensionsOption); + const { name, extension } = getFilenameWithExtensionOption(getBaseFileName(filePath), host.getCompilationSettings(), extensionOptions); result.add(nameAndKind(name, ScriptElementKind.scriptElement, extension)); } } @@ -689,17 +690,32 @@ function getCompletionEntriesForDirectoryFragment( return result; } -function getFilenameWithExtensionOption(name: string, compilerOptions: CompilerOptions, includeExtensionsOption: IncludeExtensionsOption): { name: string, extension: Extension | undefined } { - const outputExtension = moduleSpecifiers.tryGetJSExtensionForFile(name, compilerOptions); - if (includeExtensionsOption === IncludeExtensionsOption.Exclude && !fileExtensionIsOneOf(name, [Extension.Json, Extension.Mts, Extension.Cts, Extension.Dmts, Extension.Dcts, Extension.Mjs, Extension.Cjs])) { - return { name: removeFileExtension(name), extension: tryGetExtensionFromPath(name) }; +function getFilenameWithExtensionOption(name: string, compilerOptions: CompilerOptions, extensionOptions: ExtensionOptions): { name: string, extension: Extension | undefined } { + if (extensionOptions.referenceKind === ReferenceKind.Filename) { + return { name, extension: tryGetExtensionFromPath(name) }; } - else if ((fileExtensionIsOneOf(name, [Extension.Mts, Extension.Cts, Extension.Dmts, Extension.Dcts, Extension.Mjs, Extension.Cjs]) || includeExtensionsOption === IncludeExtensionsOption.ModuleSpecifierCompletion) && outputExtension) { - return { name: changeExtension(name, outputExtension), extension: outputExtension }; + + const endingPreference = getModuleSpecifierEndingPreference(extensionOptions.endingPreference, extensionOptions.resolutionMode, compilerOptions, extensionOptions.importingSourceFile); + if (endingPreference === ModuleSpecifierEnding.TsExtension) { + if (fileExtensionIsOneOf(name, supportedTSImplementationExtensions)) { + return { name, extension: tryGetExtensionFromPath(name) }; + } + const outputExtension = moduleSpecifiers.tryGetJSExtensionForFile(name, compilerOptions); + return outputExtension + ? { name: changeExtension(name, outputExtension), extension: outputExtension } + : { name, extension: tryGetExtensionFromPath(name) }; } - else { - return { name, extension: tryGetExtensionFromPath(name) }; + + if ((endingPreference === ModuleSpecifierEnding.Minimal || endingPreference === ModuleSpecifierEnding.Index) && + fileExtensionIsOneOf(name, [Extension.Js, Extension.Jsx, Extension.Ts, Extension.Tsx, Extension.Dts]) + ) { + return { name: removeFileExtension(name), extension: tryGetExtensionFromPath(name) }; } + + const outputExtension = moduleSpecifiers.tryGetJSExtensionForFile(name, compilerOptions); + return outputExtension + ? { name: changeExtension(name, outputExtension), extension: outputExtension } + : { name, extension: tryGetExtensionFromPath(name) }; } /** @returns whether `fragment` was a match for any `paths` (which should indicate whether any other path completions should be offered) */ @@ -786,17 +802,17 @@ function getCompletionEntriesForNonRelativeModules( mode: ResolutionMode, compilerOptions: CompilerOptions, host: LanguageServiceHost, - includeExtensionsOption: IncludeExtensionsOption, + extensionOptions: ExtensionOptions, typeChecker: TypeChecker, ): readonly NameAndKind[] { const { baseUrl, paths } = compilerOptions; const result = createNameAndKindSet(); - const extensionOptions = getExtensionOptions(compilerOptions, includeExtensionsOption); + const moduleResolution = getEmitModuleResolutionKind(compilerOptions); if (baseUrl) { const projectDir = compilerOptions.project || host.getCurrentDirectory(); const absolute = normalizePath(combinePaths(projectDir, baseUrl)); - getCompletionEntriesForDirectoryFragment(fragment, absolute, extensionOptions, host, /*exclude*/ undefined, result); + getCompletionEntriesForDirectoryFragment(fragment, absolute, extensionOptions, host, /*moduleSpecifierIsRelative*/ false, /*exclude*/ undefined, result); if (paths) { addCompletionEntriesFromPaths(result, fragment, absolute, extensionOptions, host, paths); } @@ -809,7 +825,7 @@ function getCompletionEntriesForNonRelativeModules( getCompletionEntriesFromTypings(host, compilerOptions, scriptPath, fragmentDirectory, extensionOptions, result); - if (isEmitResolutionKindUsingNodeModules(compilerOptions)) { + if (moduleResolutionUsesNodeModules(moduleResolution)) { // If looking for a global package name, don't just include everything in `node_modules` because that includes dependencies' own dependencies. // (But do if we didn't find anything, e.g. 'package.json' missing.) let foundGlobal = false; @@ -826,10 +842,10 @@ function getCompletionEntriesForNonRelativeModules( let ancestorLookup: (directory: string) => void | undefined = ancestor => { const nodeModules = combinePaths(ancestor, "node_modules"); if (tryDirectoryExists(host, nodeModules)) { - getCompletionEntriesForDirectoryFragment(fragment, nodeModules, extensionOptions, host, /*exclude*/ undefined, result); + getCompletionEntriesForDirectoryFragment(fragment, nodeModules, extensionOptions, host, /*moduleSpecifierIsRelative*/ false, /*exclude*/ undefined, result); } }; - if (fragmentDirectory && isEmitModuleResolutionRespectingExportMaps(compilerOptions)) { + if (fragmentDirectory && moduleResolutionRespectsExports(moduleResolution)) { const nodeModulesDirectoryLookup = ancestorLookup; ancestorLookup = ancestor => { const components = getPathComponents(fragment); @@ -967,13 +983,13 @@ function getModulesForPathsPattern( // done. const includeGlob = normalizedSuffix ? "**/*" + normalizedSuffix : "./*"; - const matches = mapDefined(tryReadDirectory(host, baseDirectory, extensionOptions.extensions, /*exclude*/ undefined, [includeGlob]), match => { + const matches = mapDefined(tryReadDirectory(host, baseDirectory, extensionOptions.extensionsToSearch, /*exclude*/ undefined, [includeGlob]), match => { const trimmedWithPattern = trimPrefixAndSuffix(match); if (trimmedWithPattern) { if (containsSlash(trimmedWithPattern)) { return directoryResult(getPathComponents(removeLeadingDirectorySeparator(trimmedWithPattern))[1]); } - const { name, extension } = getFilenameWithExtensionOption(trimmedWithPattern, host.getCompilationSettings(), extensionOptions.includeExtensionsOption); + const { name, extension } = getFilenameWithExtensionOption(trimmedWithPattern, host.getCompilationSettings(), extensionOptions); return nameAndKind(name, ScriptElementKind.scriptElement, extension); } }); @@ -1030,8 +1046,8 @@ function getTripleSlashReferenceCompletion(sourceFile: SourceFile, position: num const [, prefix, kind, toComplete] = match; const scriptPath = getDirectoryPath(sourceFile.path); - const names = kind === "path" ? getCompletionEntriesForDirectoryFragment(toComplete, scriptPath, getExtensionOptions(compilerOptions, IncludeExtensionsOption.Include), host, sourceFile.path) - : kind === "types" ? getCompletionEntriesFromTypings(host, compilerOptions, scriptPath, getFragmentDirectory(toComplete), getExtensionOptions(compilerOptions)) + const names = kind === "path" ? getCompletionEntriesForDirectoryFragment(toComplete, scriptPath, getExtensionOptions(compilerOptions, ReferenceKind.Filename, sourceFile), host, /*moduleSpecifierIsRelative*/ true, sourceFile.path) + : kind === "types" ? getCompletionEntriesFromTypings(host, compilerOptions, scriptPath, getFragmentDirectory(toComplete), getExtensionOptions(compilerOptions, ReferenceKind.ModuleSpecifier, sourceFile)) : Debug.fail(); return addReplacementSpans(toComplete, range.pos + prefix.length, arrayFrom(names.values())); } @@ -1071,7 +1087,7 @@ function getCompletionEntriesFromTypings(host: LanguageServiceHost, options: Com const baseDirectory = combinePaths(directory, typeDirectoryName); const remainingFragment = tryRemoveDirectoryPrefix(fragmentDirectory, packageName, hostGetCanonicalFileName(host)); if (remainingFragment !== undefined) { - getCompletionEntriesForDirectoryFragment(remainingFragment, baseDirectory, extensionOptions, host, /*exclude*/ undefined, result); + getCompletionEntriesForDirectoryFragment(remainingFragment, baseDirectory, extensionOptions, host, /*moduleSpecifierIsRelative*/ false, /*exclude*/ undefined, result); } } } diff --git a/src/services/utilities.ts b/src/services/utilities.ts index ac93d22ba7699..1ef0c812790b6 100644 --- a/src/services/utilities.ts +++ b/src/services/utilities.ts @@ -2374,6 +2374,7 @@ export function programContainsModules(program: Program): boolean { export function programContainsEsModules(program: Program): boolean { return program.getSourceFiles().some(s => !s.isDeclarationFile && !program.isSourceFileFromExternalLibrary(s) && !!s.externalModuleIndicator); } +// TODO: this function is, at best, poorly named. Use sites are pretty suspicious. /** @internal */ export function compilerOptionsIndicateEsModules(compilerOptions: CompilerOptions): boolean { return !!compilerOptions.module || getEmitScriptTarget(compilerOptions) >= ScriptTarget.ES2015 || !!compilerOptions.noEmit; diff --git a/src/testRunner/compilerRunner.ts b/src/testRunner/compilerRunner.ts index bad01ecd0bb01..8bd62f4f62e64 100644 --- a/src/testRunner/compilerRunner.ts +++ b/src/testRunner/compilerRunner.ts @@ -135,8 +135,10 @@ class CompilerTest { "module", "moduleResolution", "moduleDetection", + "allowImportingTsExtensions", "target", "jsx", + "noEmit", "removeComments", "importHelpers", "importHelpers", diff --git a/src/testRunner/unittests/helpers.ts b/src/testRunner/unittests/helpers.ts index 725fb7c4e60fb..4e5f2046a416a 100644 --- a/src/testRunner/unittests/helpers.ts +++ b/src/testRunner/unittests/helpers.ts @@ -170,4 +170,4 @@ export function updateProgram(oldProgram: ProgramWithSourceTexts, rootNames: rea export function updateProgramText(files: readonly NamedSourceText[], fileName: string, newProgramText: string) { const file = ts.find(files, f => f.name === fileName)!; file.text = file.text.updateProgram(newProgramText); -} \ No newline at end of file +} diff --git a/src/testRunner/unittests/moduleResolution.ts b/src/testRunner/unittests/moduleResolution.ts index 869a86894dc93..cebf0c1545491 100644 --- a/src/testRunner/unittests/moduleResolution.ts +++ b/src/testRunner/unittests/moduleResolution.ts @@ -169,6 +169,7 @@ describe("unittests:: moduleResolution:: Node module resolution - non-relative p resolvedFileName: "/sub/node_modules/a/index.ts", isExternalLibraryImport: true, extension: ts.Extension.Ts, + resolvedUsingTsExtension: false, }, failedLookupLocations: [], affectingLocations: [], @@ -184,6 +185,7 @@ describe("unittests:: moduleResolution:: Node module resolution - non-relative p resolvedFileName: "/sub/directory/node_modules/b/index.ts", isExternalLibraryImport: true, extension: ts.Extension.Ts, + resolvedUsingTsExtension: false, }, failedLookupLocations: [], affectingLocations: [], @@ -201,6 +203,7 @@ describe("unittests:: moduleResolution:: Node module resolution - non-relative p resolvedFileName: "/bar/node_modules/c/index.ts", isExternalLibraryImport: true, extension: ts.Extension.Ts, + resolvedUsingTsExtension: false, }, failedLookupLocations: [], affectingLocations: [], @@ -217,6 +220,7 @@ describe("unittests:: moduleResolution:: Node module resolution - non-relative p resolvedFileName: "/foo/index.ts", isExternalLibraryImport: true, extension: ts.Extension.Ts, + resolvedUsingTsExtension: false, }, failedLookupLocations: [], affectingLocations: [], @@ -232,6 +236,7 @@ describe("unittests:: moduleResolution:: Node module resolution - non-relative p resolvedFileName: "d:/bar/node_modules/e/index.ts", isExternalLibraryImport: true, extension: ts.Extension.Ts, + resolvedUsingTsExtension: false, }, failedLookupLocations: [], affectingLocations: [], diff --git a/src/testRunner/unittests/tscWatch/watchApi.ts b/src/testRunner/unittests/tscWatch/watchApi.ts index 09be991f9a308..49dc60cbd414f 100644 --- a/src/testRunner/unittests/tscWatch/watchApi.ts +++ b/src/testRunner/unittests/tscWatch/watchApi.ts @@ -50,6 +50,7 @@ describe("unittests:: tsc-watch:: watchAPI:: tsc-watch with custom module resolu resolvedFileName: resolvedModule.resolvedFileName, isExternalLibraryImport: resolvedModule.isExternalLibraryImport, originalFileName: resolvedModule.originalPath, + resolvedUsingTsExtension: false, }; }); const watch = ts.createWatchProgram(host); diff --git a/tests/baselines/reference/api/tsserverlibrary.d.ts b/tests/baselines/reference/api/tsserverlibrary.d.ts index 0af2b39653975..a127bd35f7ab6 100644 --- a/tests/baselines/reference/api/tsserverlibrary.d.ts +++ b/tests/baselines/reference/api/tsserverlibrary.d.ts @@ -7022,6 +7022,7 @@ declare namespace ts { } type CompilerOptionsValue = string | number | boolean | (string | number)[] | string[] | MapLike | PluginImport[] | ProjectReference[] | null | undefined; interface CompilerOptions { + allowImportingTsExtensions?: boolean; allowJs?: boolean; allowSyntheticDefaultImports?: boolean; allowUmdGlobalAccess?: boolean; @@ -7266,6 +7267,11 @@ declare namespace ts { resolvedFileName: string; /** True if `resolvedFileName` comes from `node_modules`. */ isExternalLibraryImport?: boolean; + /** + * True if the original module reference used a .ts extension to refer directly to a .ts file, + * which should produce an error during checking if emit is enabled. + */ + resolvedUsingTsExtension: boolean; } /** * ResolvedModule with an explicitly provided `extension` property. @@ -8772,6 +8778,7 @@ declare namespace ts { parent: ConstructorDeclaration; name: Identifier; }; + function emitModuleKindIsNonNodeESM(moduleKind: ModuleKind): boolean; function isJSDocOptionalParameter(node: ParameterDeclaration): boolean; function isOptionalDeclaration(declaration: Declaration): boolean; function createUnparsedSourceFile(text: string): UnparsedSource; @@ -9204,6 +9211,8 @@ declare namespace ts { function resolveModuleName(moduleName: string, containingFile: string, compilerOptions: CompilerOptions, host: ModuleResolutionHost, cache?: ModuleResolutionCache, redirectedReference?: ResolvedProjectReference, resolutionMode?: ResolutionMode): ResolvedModuleWithFailedLookupLocations; function nodeModuleNameResolver(moduleName: string, containingFile: string, compilerOptions: CompilerOptions, host: ModuleResolutionHost, cache?: ModuleResolutionCache, redirectedReference?: ResolvedProjectReference): ResolvedModuleWithFailedLookupLocations; function classicNameResolver(moduleName: string, containingFile: string, compilerOptions: CompilerOptions, host: ModuleResolutionHost, cache?: NonRelativeModuleNameResolutionCache, redirectedReference?: ResolvedProjectReference): ResolvedModuleWithFailedLookupLocations; + function moduleResolutionSupportsResolvingTsExtensions(_compilerOptions: CompilerOptions): boolean; + function shouldAllowImportingTsExtension(compilerOptions: CompilerOptions, fromFileName?: string): boolean | "" | undefined; interface TypeReferenceDirectiveResolutionCache extends PerDirectoryResolutionCache, NonRelativeNameResolutionCache, PackageJsonInfoCache { } interface ModeAwareCache { diff --git a/tests/baselines/reference/api/typescript.d.ts b/tests/baselines/reference/api/typescript.d.ts index eaf5c1e2626fa..9669468b81d8f 100644 --- a/tests/baselines/reference/api/typescript.d.ts +++ b/tests/baselines/reference/api/typescript.d.ts @@ -3088,6 +3088,7 @@ declare namespace ts { } type CompilerOptionsValue = string | number | boolean | (string | number)[] | string[] | MapLike | PluginImport[] | ProjectReference[] | null | undefined; interface CompilerOptions { + allowImportingTsExtensions?: boolean; allowJs?: boolean; allowSyntheticDefaultImports?: boolean; allowUmdGlobalAccess?: boolean; @@ -3332,6 +3333,11 @@ declare namespace ts { resolvedFileName: string; /** True if `resolvedFileName` comes from `node_modules`. */ isExternalLibraryImport?: boolean; + /** + * True if the original module reference used a .ts extension to refer directly to a .ts file, + * which should produce an error during checking if emit is enabled. + */ + resolvedUsingTsExtension: boolean; } /** * ResolvedModule with an explicitly provided `extension` property. @@ -4838,6 +4844,7 @@ declare namespace ts { parent: ConstructorDeclaration; name: Identifier; }; + function emitModuleKindIsNonNodeESM(moduleKind: ModuleKind): boolean; function isJSDocOptionalParameter(node: ParameterDeclaration): boolean; function isOptionalDeclaration(declaration: Declaration): boolean; function createUnparsedSourceFile(text: string): UnparsedSource; @@ -5270,6 +5277,8 @@ declare namespace ts { function resolveModuleName(moduleName: string, containingFile: string, compilerOptions: CompilerOptions, host: ModuleResolutionHost, cache?: ModuleResolutionCache, redirectedReference?: ResolvedProjectReference, resolutionMode?: ResolutionMode): ResolvedModuleWithFailedLookupLocations; function nodeModuleNameResolver(moduleName: string, containingFile: string, compilerOptions: CompilerOptions, host: ModuleResolutionHost, cache?: ModuleResolutionCache, redirectedReference?: ResolvedProjectReference): ResolvedModuleWithFailedLookupLocations; function classicNameResolver(moduleName: string, containingFile: string, compilerOptions: CompilerOptions, host: ModuleResolutionHost, cache?: NonRelativeModuleNameResolutionCache, redirectedReference?: ResolvedProjectReference): ResolvedModuleWithFailedLookupLocations; + function moduleResolutionSupportsResolvingTsExtensions(_compilerOptions: CompilerOptions): boolean; + function shouldAllowImportingTsExtension(compilerOptions: CompilerOptions, fromFileName?: string): boolean | "" | undefined; interface TypeReferenceDirectiveResolutionCache extends PerDirectoryResolutionCache, NonRelativeNameResolutionCache, PackageJsonInfoCache { } interface ModeAwareCache { diff --git a/tests/baselines/reference/showConfig/Shows tsconfig for single option/allowImportingTsExtensions/tsconfig.json b/tests/baselines/reference/showConfig/Shows tsconfig for single option/allowImportingTsExtensions/tsconfig.json new file mode 100644 index 0000000000000..88c95f9eb8307 --- /dev/null +++ b/tests/baselines/reference/showConfig/Shows tsconfig for single option/allowImportingTsExtensions/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "allowImportingTsExtensions": true + } +} diff --git a/tests/cases/fourslash/fourslash.ts b/tests/cases/fourslash/fourslash.ts index 7d106808e7982..5bbbd00212ccb 100644 --- a/tests/cases/fourslash/fourslash.ts +++ b/tests/cases/fourslash/fourslash.ts @@ -375,6 +375,7 @@ declare namespace FourSlashInterface { getAndApplyCodeFix(errorCode?: number, index?: number): void; importFixAtPosition(expectedTextArray: string[], errorCode?: number, options?: UserPreferences): void; importFixModuleSpecifiers(marker: string, moduleSpecifiers: string[], options?: UserPreferences): void; + baselineAutoImports(marker: string, options?: UserPreferences): void; navigationBar(json: any, options?: { checkSpans?: boolean }): void; navigationTree(json: any, options?: { checkSpans?: boolean }): void;