From 707d0b36c2c00d6611ebd0d501b8b753de8845ff Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Sun, 26 Jun 2022 14:59:49 -0400 Subject: [PATCH 1/5] quick impl --- src/configuration.ts | 2 ++ src/index.ts | 3 +++ src/resolver-functions.ts | 22 +++++++++++++++++++++- 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/configuration.ts b/src/configuration.ts index 5142a3584..266f2d920 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -383,6 +383,7 @@ function filterRecognizedTsConfigTsNodeOptions(jsonObject: any): { experimentalResolver, esm, experimentalSpecifierResolution, + experimentalTsImportSpecifiers, ...unrecognized } = jsonObject as TsConfigOptions; const filteredTsConfigOptions = { @@ -409,6 +410,7 @@ function filterRecognizedTsConfigTsNodeOptions(jsonObject: any): { experimentalResolver, esm, experimentalSpecifierResolution, + experimentalTsImportSpecifiers, }; // Use the typechecker to make sure this implementation has the correct set of properties const catchExtraneousProps: keyof TsConfigOptions = diff --git a/src/index.ts b/src/index.ts index 799731f2a..3a33e303e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -373,6 +373,7 @@ export interface CreateOptions { * For details, see https://nodejs.org/dist/latest-v18.x/docs/api/esm.html#customizing-esm-specifier-resolution-algorithm */ experimentalSpecifierResolution?: 'node' | 'explicit'; + experimentalTsImportSpecifiers?: boolean; } export type ModuleTypes = Record; @@ -985,6 +986,7 @@ export function createFromPreloadedConfig( cwd, config, projectLocalResolveHelper, + options, }); serviceHost.resolveModuleNames = resolveModuleNames; serviceHost.getResolvedModuleWithFailedLookupLocationsFromCache = @@ -1143,6 +1145,7 @@ export function createFromPreloadedConfig( ts, getCanonicalFileName, projectLocalResolveHelper, + options, }); host.resolveModuleNames = resolveModuleNames; host.resolveTypeReferenceDirectives = resolveTypeReferenceDirectives; diff --git a/src/resolver-functions.ts b/src/resolver-functions.ts index afe13b463..49761a291 100644 --- a/src/resolver-functions.ts +++ b/src/resolver-functions.ts @@ -1,4 +1,5 @@ import { resolve } from 'path'; +import type { CreateOptions } from '.'; import type { TSCommon, TSInternal } from './ts-compiler-types'; import type { ProjectLocalResolveHelper } from './util'; @@ -13,6 +14,7 @@ export function createResolverFunctions(kwargs: { getCanonicalFileName: (filename: string) => string; config: TSCommon.ParsedCommandLine; projectLocalResolveHelper: ProjectLocalResolveHelper; + options: CreateOptions; }) { const { host, @@ -21,6 +23,7 @@ export function createResolverFunctions(kwargs: { cwd, getCanonicalFileName, projectLocalResolveHelper, + options, } = kwargs; const moduleResolutionCache = ts.createModuleResolutionCache( cwd, @@ -105,7 +108,7 @@ export function createResolverFunctions(kwargs: { i ) : undefined; - const { resolvedModule } = ts.resolveModuleName( + let { resolvedModule } = ts.resolveModuleName( moduleName, containingFile, config.options, @@ -114,6 +117,23 @@ export function createResolverFunctions(kwargs: { redirectedReference, mode ); + if(!resolvedModule && options.experimentalTsImportSpecifiers) { + const tsExtMatch = moduleName.match(/\.(?:ts|tsx|cts|mts)$/); + if(tsExtMatch) { + for(const replacementExt of ['.js', '.jsx', '.cjs', '.mjs']) { + ({resolvedModule} = ts.resolveModuleName( + moduleName.slice(0, -tsExtMatch[0].length) + replacementExt, + containingFile, + config.options, + host, + moduleResolutionCache, + redirectedReference, + mode + )); + if(resolvedModule) break; + } + } + } if (resolvedModule) { fixupResolvedModule(resolvedModule); } From 49ea57b36e5b8172c8928970451a52b05b9c633b Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Sun, 26 Jun 2022 15:08:54 -0400 Subject: [PATCH 2/5] fix --- example/foo.ts | 6 ++++++ example/index.ts | 2 ++ example/tsconfig.json | 6 ++++++ src/index.ts | 5 +++++ src/resolver-functions.ts | 10 +++++----- 5 files changed, 24 insertions(+), 5 deletions(-) create mode 100644 example/foo.ts create mode 100644 example/index.ts create mode 100644 example/tsconfig.json diff --git a/example/foo.ts b/example/foo.ts new file mode 100644 index 000000000..75fce7284 --- /dev/null +++ b/example/foo.ts @@ -0,0 +1,6 @@ +console.log(__filename); + +export const foo = true; + +// I was using this to prove that typechecking worked +// export const bar: string = true; diff --git a/example/index.ts b/example/index.ts new file mode 100644 index 000000000..4aa8ef090 --- /dev/null +++ b/example/index.ts @@ -0,0 +1,2 @@ +import {foo} from './foo.ts'; +console.log({foo}); diff --git a/example/tsconfig.json b/example/tsconfig.json new file mode 100644 index 000000000..bf15dfe53 --- /dev/null +++ b/example/tsconfig.json @@ -0,0 +1,6 @@ +{ + "ts-node": { + // Can eventually make this a stable feature. For now, `experimental` flag allows me to iterate quickly + "experimentalTsImportSpecifiers": true + } +} diff --git a/src/index.ts b/src/index.ts index 3a33e303e..a7be69bea 100644 --- a/src/index.ts +++ b/src/index.ts @@ -694,6 +694,11 @@ export function createFromPreloadedConfig( 6059, // "'rootDir' is expected to contain all source files." 18002, // "The 'files' list in config file is empty." 18003, // "No inputs were found in config file." + ...(options.experimentalTsImportSpecifiers + ? [ + 2691, // "An import path cannot end with a '.ts' extension. Consider importing '' instead." + ] + : []), ...(options.ignoreDiagnostics || []), ].map(Number), }, diff --git a/src/resolver-functions.ts b/src/resolver-functions.ts index 49761a291..dbe49840f 100644 --- a/src/resolver-functions.ts +++ b/src/resolver-functions.ts @@ -117,11 +117,11 @@ export function createResolverFunctions(kwargs: { redirectedReference, mode ); - if(!resolvedModule && options.experimentalTsImportSpecifiers) { + if (!resolvedModule && options.experimentalTsImportSpecifiers) { const tsExtMatch = moduleName.match(/\.(?:ts|tsx|cts|mts)$/); - if(tsExtMatch) { - for(const replacementExt of ['.js', '.jsx', '.cjs', '.mjs']) { - ({resolvedModule} = ts.resolveModuleName( + if (tsExtMatch) { + for (const replacementExt of ['.js', '.jsx', '.cjs', '.mjs']) { + ({ resolvedModule } = ts.resolveModuleName( moduleName.slice(0, -tsExtMatch[0].length) + replacementExt, containingFile, config.options, @@ -130,7 +130,7 @@ export function createResolverFunctions(kwargs: { redirectedReference, mode )); - if(resolvedModule) break; + if (resolvedModule) break; } } } From 363a08e5ee5c34af12ff120f8ce73d29f1d5517a Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Sun, 26 Jun 2022 15:53:08 -0400 Subject: [PATCH 3/5] update --- example/bar.tsx | 0 example/index.ts | 1 + example/tsconfig.json | 3 +++ src/file-extensions.ts | 20 ++++++++++++++++++++ src/index.ts | 5 ++++- src/resolver-functions.ts | 13 +++++++++---- 6 files changed, 37 insertions(+), 5 deletions(-) create mode 100644 example/bar.tsx diff --git a/example/bar.tsx b/example/bar.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/example/index.ts b/example/index.ts index 4aa8ef090..e397f640f 100644 --- a/example/index.ts +++ b/example/index.ts @@ -1,2 +1,3 @@ import {foo} from './foo.ts'; +import {foo} from './bar.jsx'; console.log({foo}); diff --git a/example/tsconfig.json b/example/tsconfig.json index bf15dfe53..861c4507f 100644 --- a/example/tsconfig.json +++ b/example/tsconfig.json @@ -2,5 +2,8 @@ "ts-node": { // Can eventually make this a stable feature. For now, `experimental` flag allows me to iterate quickly "experimentalTsImportSpecifiers": true + }, + "compilerOptions": { + "jsx": true } } diff --git a/src/file-extensions.ts b/src/file-extensions.ts index 87e8be1c6..b5fd03552 100644 --- a/src/file-extensions.ts +++ b/src/file-extensions.ts @@ -19,6 +19,13 @@ const nodeEquivalents = new Map([ ['.cts', '.cjs'], ]); +const tsResolverEquivalents = new Map([ + ['.ts', ['.js']], + ['.tsx', ['.js', '.jsx']], + ['.mts', ['.mjs']], + ['.cts', ['.cjs']], +]); + // All extensions understood by vanilla node const vanillaNodeExtensions: readonly string[] = [ '.js', @@ -129,6 +136,19 @@ export function getExtensions( * as far as getFormat is concerned. */ nodeEquivalents, + /** + * Mapping from extensions rejected by TSC in import specifiers, to the + * possible alternatives that TS's resolver will accept. + * + * When we allow users to opt-in to .ts extensions in import specifiers, TS's + * resolver requires us to replace the .ts extensions with .js alternatives. + * Otherwise, resolution fails. + * + * Note TS's resolver is only used by, and only required for, typechecking. + * This is separate from node's resolver, which we hook separately and which + * does not require this mapping. + */ + tsResolverEquivalents, /** * Extensions that we can support if the user upgrades their typescript version. * Used when raising hints. diff --git a/src/index.ts b/src/index.ts index a7be69bea..5d15bcba8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -911,6 +911,8 @@ export function createFromPreloadedConfig( patterns: options.moduleTypes, }); + const extensions = getExtensions(config, options, ts.version); + // Use full language services when the fast option is disabled. if (!transpileOnly) { const fileContents = new Map(); @@ -992,6 +994,7 @@ export function createFromPreloadedConfig( config, projectLocalResolveHelper, options, + extensions, }); serviceHost.resolveModuleNames = resolveModuleNames; serviceHost.getResolvedModuleWithFailedLookupLocationsFromCache = @@ -1151,6 +1154,7 @@ export function createFromPreloadedConfig( getCanonicalFileName, projectLocalResolveHelper, options, + extensions, }); host.resolveModuleNames = resolveModuleNames; host.resolveTypeReferenceDirectives = resolveTypeReferenceDirectives; @@ -1456,7 +1460,6 @@ export function createFromPreloadedConfig( let active = true; const enabled = (enabled?: boolean) => enabled === undefined ? active : (active = !!enabled); - const extensions = getExtensions(config, options, ts.version); const ignored = (fileName: string) => { if (!active) return true; const ext = extname(fileName); diff --git a/src/resolver-functions.ts b/src/resolver-functions.ts index dbe49840f..83568669c 100644 --- a/src/resolver-functions.ts +++ b/src/resolver-functions.ts @@ -1,5 +1,6 @@ import { resolve } from 'path'; import type { CreateOptions } from '.'; +import type { Extensions } from './file-extensions'; import type { TSCommon, TSInternal } from './ts-compiler-types'; import type { ProjectLocalResolveHelper } from './util'; @@ -15,6 +16,7 @@ export function createResolverFunctions(kwargs: { config: TSCommon.ParsedCommandLine; projectLocalResolveHelper: ProjectLocalResolveHelper; options: CreateOptions; + extensions: Extensions; }) { const { host, @@ -24,6 +26,7 @@ export function createResolverFunctions(kwargs: { getCanonicalFileName, projectLocalResolveHelper, options, + extensions, } = kwargs; const moduleResolutionCache = ts.createModuleResolutionCache( cwd, @@ -118,11 +121,13 @@ export function createResolverFunctions(kwargs: { mode ); if (!resolvedModule && options.experimentalTsImportSpecifiers) { - const tsExtMatch = moduleName.match(/\.(?:ts|tsx|cts|mts)$/); - if (tsExtMatch) { - for (const replacementExt of ['.js', '.jsx', '.cjs', '.mjs']) { + const lastDotIndex = moduleName.lastIndexOf('.'); + const ext = lastDotIndex >= 0 ? moduleName.slice(lastDotIndex) : ''; + if (ext) { + const replacements = extensions.tsResolverEquivalents.get(ext); + for (const replacementExt of replacements ?? []) { ({ resolvedModule } = ts.resolveModuleName( - moduleName.slice(0, -tsExtMatch[0].length) + replacementExt, + moduleName.slice(0, -ext.length) + replacementExt, containingFile, config.options, host, From 54eacce0f266e07ac79f73a6c7a1aea5de509add Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Sun, 26 Jun 2022 16:02:27 -0400 Subject: [PATCH 4/5] add a test --- example/bar.tsx | 0 example/foo.ts | 6 ----- example/index.ts | 3 --- src/test/ts-import-specifiers.spec.ts | 22 +++++++++++++++++++ tests/ts-import-specifiers/bar.tsx | 1 + tests/ts-import-specifiers/foo.ts | 1 + tests/ts-import-specifiers/index.ts | 3 +++ .../ts-import-specifiers}/tsconfig.json | 5 +++-- 8 files changed, 30 insertions(+), 11 deletions(-) delete mode 100644 example/bar.tsx delete mode 100644 example/foo.ts delete mode 100644 example/index.ts create mode 100644 src/test/ts-import-specifiers.spec.ts create mode 100644 tests/ts-import-specifiers/bar.tsx create mode 100644 tests/ts-import-specifiers/foo.ts create mode 100644 tests/ts-import-specifiers/index.ts rename {example => tests/ts-import-specifiers}/tsconfig.json (62%) diff --git a/example/bar.tsx b/example/bar.tsx deleted file mode 100644 index e69de29bb..000000000 diff --git a/example/foo.ts b/example/foo.ts deleted file mode 100644 index 75fce7284..000000000 --- a/example/foo.ts +++ /dev/null @@ -1,6 +0,0 @@ -console.log(__filename); - -export const foo = true; - -// I was using this to prove that typechecking worked -// export const bar: string = true; diff --git a/example/index.ts b/example/index.ts deleted file mode 100644 index e397f640f..000000000 --- a/example/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import {foo} from './foo.ts'; -import {foo} from './bar.jsx'; -console.log({foo}); diff --git a/src/test/ts-import-specifiers.spec.ts b/src/test/ts-import-specifiers.spec.ts new file mode 100644 index 000000000..39c4cc294 --- /dev/null +++ b/src/test/ts-import-specifiers.spec.ts @@ -0,0 +1,22 @@ +import { context } from './testlib'; +import * as expect from 'expect'; +import { createExec } from './exec-helpers'; +import { + TEST_DIR, + ctxTsNode, + CMD_TS_NODE_WITHOUT_PROJECT_FLAG, +} from './helpers'; + +const exec = createExec({ + cwd: TEST_DIR, +}); + +const test = context(ctxTsNode); + +test('Supports .ts extensions in import specifiers with typechecking, even though vanilla TS checker does not', async () => { + const { err, stdout } = await exec( + `${CMD_TS_NODE_WITHOUT_PROJECT_FLAG} ts-import-specifiers/index.ts` + ); + expect(err).toBe(null); + expect(stdout.trim()).toBe('{ foo: true, bar: true }'); +}); diff --git a/tests/ts-import-specifiers/bar.tsx b/tests/ts-import-specifiers/bar.tsx new file mode 100644 index 000000000..3a850c17c --- /dev/null +++ b/tests/ts-import-specifiers/bar.tsx @@ -0,0 +1 @@ +export const bar = true; diff --git a/tests/ts-import-specifiers/foo.ts b/tests/ts-import-specifiers/foo.ts new file mode 100644 index 000000000..62d968e82 --- /dev/null +++ b/tests/ts-import-specifiers/foo.ts @@ -0,0 +1 @@ +export const foo = true; diff --git a/tests/ts-import-specifiers/index.ts b/tests/ts-import-specifiers/index.ts new file mode 100644 index 000000000..2f1444fb5 --- /dev/null +++ b/tests/ts-import-specifiers/index.ts @@ -0,0 +1,3 @@ +import { foo } from './foo.ts'; +import { bar } from './bar.jsx'; +console.log({ foo, bar }); diff --git a/example/tsconfig.json b/tests/ts-import-specifiers/tsconfig.json similarity index 62% rename from example/tsconfig.json rename to tests/ts-import-specifiers/tsconfig.json index 861c4507f..098594e5f 100644 --- a/example/tsconfig.json +++ b/tests/ts-import-specifiers/tsconfig.json @@ -1,9 +1,10 @@ { "ts-node": { // Can eventually make this a stable feature. For now, `experimental` flag allows me to iterate quickly - "experimentalTsImportSpecifiers": true + "experimentalTsImportSpecifiers": true, + "experimentalResolver": true }, "compilerOptions": { - "jsx": true + "jsx": "react" } } From 1a03b209cf5e4e58181b8701521b2eedd8b6cbb3 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Sat, 2 Jul 2022 15:22:15 -0400 Subject: [PATCH 5/5] add jsdoc for new option --- src/index.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/index.ts b/src/index.ts index 5d15bcba8..babf493ce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -373,6 +373,16 @@ export interface CreateOptions { * For details, see https://nodejs.org/dist/latest-v18.x/docs/api/esm.html#customizing-esm-specifier-resolution-algorithm */ experimentalSpecifierResolution?: 'node' | 'explicit'; + /** + * Allow using voluntary `.ts` file extension in import specifiers. + * + * Typically, in ESM projects, import specifiers must hanve an emit extension, `.js`, `.cjs`, or `.mjs`, + * and we automatically map to the corresponding `.ts`, `.cts`, or `.mts` source file. This is the + * recommended approach. + * + * However, if you really want to use `.ts` in import specifiers, and are aware that this may + * break tooling, you can enable this flag. + */ experimentalTsImportSpecifiers?: boolean; }