Skip to content

Commit

Permalink
Add --allowArbitraryExtensions, a flag for allowing arbitrary exten…
Browse files Browse the repository at this point in the history
…sions on import paths (#51435)
  • Loading branch information
weswigham authored Jan 10, 2023
1 parent 9b718d0 commit 89e928e
Show file tree
Hide file tree
Showing 118 changed files with 3,056 additions and 1,401 deletions.
2 changes: 1 addition & 1 deletion src/compiler/checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4738,7 +4738,7 @@ export function createTypeChecker(host: TypeCheckerHost): TypeChecker {
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 resolutionDiagnostic = resolvedModule && getResolutionDiagnostic(compilerOptions, resolvedModule, currentSourceFile);
const sourceFile = resolvedModule
&& (!resolutionDiagnostic || resolutionDiagnostic === Diagnostics.Module_0_was_resolved_to_1_but_jsx_is_not_set)
&& host.getSourceFile(resolvedModule.resolvedFileName);
Expand Down
8 changes: 8 additions & 0 deletions src/compiler/commandLineParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1206,6 +1206,14 @@ const commandOptionsWithoutBuild: CommandLineOption[] = [
description: Diagnostics.Enable_importing_json_files,
defaultValueDescription: false,
},
{
name: "allowArbitraryExtensions",
type: "boolean",
affectsModuleResolution: true,
category: Diagnostics.Modules,
description: Diagnostics.Enable_importing_files_with_any_extension_provided_a_declaration_file_is_present,
defaultValueDescription: false,
},

{
name: "out",
Expand Down
12 changes: 12 additions & 0 deletions src/compiler/diagnosticMessages.json
Original file line number Diff line number Diff line change
Expand Up @@ -5190,6 +5190,18 @@
"category": "Message",
"code": 6261
},
"File name '{0}' has a '{1}' extension - looking up '{2}' instead.": {
"category": "Message",
"code": 6262
},
"Module '{0}' was resolved to '{1}', but '--allowArbitraryExtensions' is not set.": {
"category": "Error",
"code": 6263
},
"Enable importing files with any extension, provided a declaration file is present.": {
"category": "Message",
"code": 6264
},

"Directory '{0}' has no containing package.json scope. Imports will not resolve.": {
"category": "Message",
Expand Down
89 changes: 41 additions & 48 deletions src/compiler/moduleNameResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ import {
getRelativePathFromDirectory,
getResolveJsonModule,
getRootLength,
hasJSFileExtension,
hasProperty,
hasTrailingDirectorySeparator,
hostGetCanonicalFileName,
Expand Down Expand Up @@ -99,7 +98,6 @@ import {
startsWith,
stringContains,
supportedDeclarationExtensions,
supportedTSExtensionsFlat,
supportedTSImplementationExtensions,
toPath,
tryExtractTSExtension,
Expand Down Expand Up @@ -151,7 +149,7 @@ function removeIgnoredPackageId(r: Resolved | undefined): PathAndExtension | und
/** Result of trying to resolve a module. */
interface Resolved {
path: string;
extension: Extension;
extension: string;
packageId: PackageId | undefined;
/**
* When the resolved is not created from cache, the value is
Expand All @@ -170,7 +168,7 @@ interface Resolved {
interface PathAndExtension {
path: string;
// (Use a different name than `extension` to make sure Resolved isn't assignable to PathAndExtension.)
ext: Extension;
ext: string;
resolvedUsingTsExtension: boolean | undefined;
}

Expand Down Expand Up @@ -1856,21 +1854,21 @@ 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) ||
extensions & (Extensions.TypeScript | Extensions.Declaration)
&& moduleResolutionSupportsResolvingTsExtensions(state.compilerOptions)
&& fileExtensionIsOneOf(candidate, supportedTSExtensionsFlat)
) {
const extensionless = removeFileExtension(candidate);
const extension = candidate.substring(extensionless.length);
if (state.traceEnabled) {
trace(state.host, Diagnostics.File_name_0_has_a_1_extension_stripping_it, candidate, extension);
}
return tryAddingExtensions(extensionless, extensions, extension, onlyRecordFailures, state);
const filename = getBaseFileName(candidate);
if (filename.indexOf(".") === -1) {
return undefined; // extensionless import, no lookups performed, since we don't support extensionless files
}
let extensionless = removeFileExtension(candidate);
if (extensionless === candidate) {
// Once TS native extensions are handled, handle arbitrary extensions for declaration file mapping
extensionless = candidate.substring(0, candidate.lastIndexOf("."));
}

const extension = candidate.substring(extensionless.length);
if (state.traceEnabled) {
trace(state.host, Diagnostics.File_name_0_has_a_1_extension_stripping_it, candidate, extension);
}
return tryAddingExtensions(extensionless, extensions, extension, onlyRecordFailures, state);
}

/**
Expand Down Expand Up @@ -1909,47 +1907,46 @@ function tryAddingExtensions(candidate: string, extensions: Extensions, original
case Extension.Mjs:
case Extension.Mts:
case Extension.Dmts:
return extensions & Extensions.TypeScript && tryExtension(Extension.Mts)
|| extensions & Extensions.Declaration && tryExtension(Extension.Dmts)
return extensions & Extensions.TypeScript && tryExtension(Extension.Mts, originalExtension === Extension.Mts || originalExtension === Extension.Dmts)
|| extensions & Extensions.Declaration && tryExtension(Extension.Dmts, originalExtension === Extension.Mts || originalExtension === Extension.Dmts)
|| extensions & Extensions.JavaScript && tryExtension(Extension.Mjs)
|| undefined;
case Extension.Cjs:
case Extension.Cts:
case Extension.Dcts:
return extensions & Extensions.TypeScript && tryExtension(Extension.Cts)
|| extensions & Extensions.Declaration && tryExtension(Extension.Dcts)
return extensions & Extensions.TypeScript && tryExtension(Extension.Cts, originalExtension === Extension.Cts || originalExtension === Extension.Dcts)
|| extensions & Extensions.Declaration && tryExtension(Extension.Dcts, originalExtension === Extension.Cts || originalExtension === Extension.Dcts)
|| extensions & Extensions.JavaScript && tryExtension(Extension.Cjs)
|| undefined;
case Extension.Json:
const originalCandidate = candidate;
if (extensions & Extensions.Declaration) {
candidate += Extension.Json;
const result = tryExtension(Extension.Dts);
if (result) return result;
}
if (extensions & Extensions.Json) {
candidate = originalCandidate;
const result = tryExtension(Extension.Json);
if (result) return result;
}
return undefined;
case Extension.Ts:
return extensions & Extensions.Declaration && tryExtension(".d.json.ts")
|| extensions & Extensions.Json && tryExtension(Extension.Json)
|| undefined;
case Extension.Tsx:
case Extension.Jsx:
// basically idendical to the ts/js case below, but prefers matching tsx and jsx files exactly before falling back to the ts or js file path
// (historically, we disallow having both a a.ts and a.tsx file in the same compilation, since their outputs clash)
// TODO: We should probably error if `"./a.tsx"` resolved to `"./a.ts"`, right?
return extensions & Extensions.TypeScript && (tryExtension(Extension.Tsx, originalExtension === Extension.Tsx) || tryExtension(Extension.Ts, originalExtension === Extension.Tsx))
|| extensions & Extensions.Declaration && tryExtension(Extension.Dts, originalExtension === Extension.Tsx)
|| extensions & Extensions.JavaScript && (tryExtension(Extension.Jsx) || tryExtension(Extension.Js))
|| undefined;
case Extension.Ts:
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)
case Extension.Js:
case "":
return extensions & Extensions.TypeScript && (tryExtension(Extension.Ts, originalExtension === Extension.Ts || originalExtension === Extension.Dts) || tryExtension(Extension.Tsx, originalExtension === Extension.Ts || originalExtension === Extension.Dts))
|| extensions & Extensions.Declaration && tryExtension(Extension.Dts, originalExtension === Extension.Ts || originalExtension === Extension.Dts)
|| extensions & Extensions.JavaScript && (tryExtension(Extension.Js) || tryExtension(Extension.Jsx))
|| state.isConfigLookup && tryExtension(Extension.Json)
|| undefined;
default:
return extensions & Extensions.Declaration && !isDeclarationFileName(candidate + originalExtension) && tryExtension(`.d${originalExtension}.ts`)
|| undefined;

}

function tryExtension(ext: Extension, resolvedUsingTsExtension?: boolean): PathAndExtension | undefined {
function tryExtension(ext: string, resolvedUsingTsExtension?: boolean): PathAndExtension | undefined {
const path = tryFile(candidate + ext, onlyRecordFailures, state);
return path === undefined ? undefined : { path, ext, resolvedUsingTsExtension };
}
Expand Down Expand Up @@ -2984,14 +2981,10 @@ export function classicNameResolver(moduleName: string, containingFile: string,
}
}

export function moduleResolutionSupportsResolvingTsExtensions(compilerOptions: CompilerOptions) {
return getEmitModuleResolutionKind(compilerOptions) === ModuleResolutionKind.Bundler;
}

// 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) && (
return getEmitModuleResolutionKind(compilerOptions) === ModuleResolutionKind.Bundler && (
!!compilerOptions.allowImportingTsExtensions ||
fromFileName && isDeclarationFileName(fromFileName));
}
Expand Down
15 changes: 15 additions & 0 deletions src/compiler/moduleSpecifiers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
flatten,
forEach,
forEachAncestorDirectory,
getBaseFileName,
GetCanonicalFileName,
getDirectoryPath,
getEmitModuleResolutionKind,
Expand Down Expand Up @@ -85,6 +86,7 @@ import {
pathIsBareSpecifier,
pathIsRelative,
PropertyAccessExpression,
removeExtension,
removeFileExtension,
removeSuffix,
ResolutionMode,
Expand Down Expand Up @@ -1036,6 +1038,10 @@ function processEnding(fileName: string, allowedEndings: readonly ModuleSpecifie
if (fileExtensionIsOneOf(fileName, [Extension.Dmts, Extension.Mts, Extension.Dcts, Extension.Cts])) {
return noExtension + getJSExtensionForFile(fileName, options);
}
else if (!fileExtensionIsOneOf(fileName, [Extension.Dts]) && fileExtensionIsOneOf(fileName, [Extension.Ts]) && stringContains(fileName, ".d.")) {
// `foo.d.json.ts` and the like - remap back to `foo.json`
return tryGetRealFileNameForNonJsDeclarationFileName(fileName)!;
}

switch (allowedEndings[0]) {
case ModuleSpecifierEnding.Minimal:
Expand Down Expand Up @@ -1066,6 +1072,15 @@ function processEnding(fileName: string, allowedEndings: readonly ModuleSpecifie
}
}

/** @internal */
export function tryGetRealFileNameForNonJsDeclarationFileName(fileName: string) {
const baseName = getBaseFileName(fileName);
if (!endsWith(fileName, Extension.Ts) || !stringContains(baseName, ".d.") || fileExtensionIsOneOf(baseName, [Extension.Dts])) return undefined;
const noExtension = removeExtension(fileName, Extension.Ts);
const ext = noExtension.substring(noExtension.lastIndexOf("."));
return noExtension.substring(0, noExtension.indexOf(".d.")) + ext;
}

function getJSExtensionForFile(fileName: string, options: CompilerOptions): Extension {
return tryGetJSExtensionForFile(fileName, options) ?? Debug.fail(`Extension ${extensionFromPath(fileName)} is unsupported:: FileName:: ${fileName}`);
}
Expand Down
6 changes: 5 additions & 1 deletion src/compiler/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,9 @@ import {
Expression,
ExpressionStatement,
ExpressionWithTypeArguments,
Extension,
ExternalModuleReference,
fileExtensionIs,
fileExtensionIsOneOf,
findIndex,
forEach,
Expand All @@ -98,6 +100,7 @@ import {
FunctionOrConstructorTypeNode,
FunctionTypeNode,
GetAccessorDeclaration,
getBaseFileName,
getBinaryOperatorPrecedence,
getFullWidth,
getJSDocCommentRanges,
Expand Down Expand Up @@ -328,6 +331,7 @@ import {
SpreadElement,
startsWith,
Statement,
stringContains,
StringLiteral,
supportedDeclarationExtensions,
SwitchStatement,
Expand Down Expand Up @@ -10114,7 +10118,7 @@ namespace IncrementalParser {

/** @internal */
export function isDeclarationFileName(fileName: string): boolean {
return fileExtensionIsOneOf(fileName, supportedDeclarationExtensions);
return fileExtensionIsOneOf(fileName, supportedDeclarationExtensions) || (fileExtensionIs(fileName, Extension.Ts) && stringContains(getBaseFileName(fileName), ".d."));
}

function parseResolutionMode(mode: string | undefined, pos: number, end: number, reportDiagnostic: PragmaDiagnosticReporter): ResolutionMode {
Expand Down
19 changes: 15 additions & 4 deletions src/compiler/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,6 @@ import {
moduleResolutionIsEqualTo,
ModuleResolutionKind,
moduleResolutionSupportsPackageJsonExportsAndImports,
moduleResolutionSupportsResolvingTsExtensions,
Mutable,
Node,
NodeArray,
Expand Down Expand Up @@ -3845,7 +3844,7 @@ export function createProgram(rootNamesOrOptions: readonly string[] | CreateProg
// Don't add the file if it has a bad extension (e.g. 'tsx' if we don't have '--allowJs')
// This may still end up being an untyped module -- the file won't be included but imports will be allowed.
const shouldAddFile = resolvedFileName
&& !getResolutionDiagnostic(optionsForFile, resolution)
&& !getResolutionDiagnostic(optionsForFile, resolution, file)
&& !optionsForFile.noResolve
&& index < file.imports.length
&& !elideImport
Expand Down Expand Up @@ -4213,7 +4212,7 @@ 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))) {
if (options.allowImportingTsExtensions && !(options.noEmit || options.emitDeclarationOnly)) {
createOptionValueDiagnostic("allowImportingTsExtensions", Diagnostics.Option_allowImportingTsExtensions_can_only_be_used_when_moduleResolution_is_set_to_bundler_and_either_noEmit_or_emitDeclarationOnly_is_set);
}

Expand Down Expand Up @@ -4947,20 +4946,28 @@ export function resolveProjectReferencePath(hostOrRef: ResolveProjectReferencePa
*
* @internal
*/
export function getResolutionDiagnostic(options: CompilerOptions, { extension }: ResolvedModuleFull): DiagnosticMessage | undefined {
export function getResolutionDiagnostic(options: CompilerOptions, { extension }: ResolvedModuleFull, { isDeclarationFile }: { isDeclarationFile: SourceFile["isDeclarationFile"] }): DiagnosticMessage | undefined {
switch (extension) {
case Extension.Ts:
case Extension.Dts:
case Extension.Mts:
case Extension.Dmts:
case Extension.Cts:
case Extension.Dcts:
// These are always allowed.
return undefined;
case Extension.Tsx:
return needJsx();
case Extension.Jsx:
return needJsx() || needAllowJs();
case Extension.Js:
case Extension.Mjs:
case Extension.Cjs:
return needAllowJs();
case Extension.Json:
return needResolveJsonModule();
default:
return needAllowArbitraryExtensions();
}

function needJsx() {
Expand All @@ -4972,6 +4979,10 @@ export function getResolutionDiagnostic(options: CompilerOptions, { extension }:
function needResolveJsonModule() {
return getResolveJsonModule(options) ? undefined : Diagnostics.Module_0_was_resolved_to_1_but_resolveJsonModule_is_not_used;
}
function needAllowArbitraryExtensions() {
// But don't report the allowArbitraryExtensions error from declaration files (no reason to report it, since the import doesn't have a runtime component)
return isDeclarationFile || options.allowArbitraryExtensions ? undefined : Diagnostics.Module_0_was_resolved_to_1_but_allowArbitraryExtensions_is_not_set;
}
}

function getModuleNames({ imports, moduleAugmentations }: SourceFile): StringLiteralLike[] {
Expand Down
3 changes: 2 additions & 1 deletion src/compiler/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6974,6 +6974,7 @@ export interface CompilerOptions {
allowImportingTsExtensions?: boolean;
allowJs?: boolean;
/** @internal */ allowNonTsExtensions?: boolean;
allowArbitraryExtensions?: boolean;
allowSyntheticDefaultImports?: boolean;
allowUmdGlobalAccess?: boolean;
allowUnreachableCode?: boolean;
Expand Down Expand Up @@ -7557,7 +7558,7 @@ export interface ResolvedModuleFull extends ResolvedModule {
* Extension of resolvedFileName. This must match what's at the end of resolvedFileName.
* This is optional for backwards-compatibility, but will be added if not provided.
*/
extension: Extension;
extension: string;
packageId?: PackageId;
}

Expand Down
Loading

0 comments on commit 89e928e

Please sign in to comment.