From d48429d97326545bb727f88ce9056270b1599a31 Mon Sep 17 00:00:00 2001 From: Edmundo Santos Date: Sat, 31 Jul 2021 18:41:49 -0300 Subject: [PATCH] feat(typescript-estree): add support for custom module resolution (#3516) --- packages/parser/README.md | 24 +++++ packages/types/src/parser-options.ts | 1 + packages/typescript-estree/README.md | 5 ++ .../create-program/createDefaultProgram.ts | 8 ++ .../src/create-program/createWatchProgram.ts | 7 ++ .../src/create-program/shared.ts | 20 ++++- .../typescript-estree/src/parser-options.ts | 22 ++++- packages/typescript-estree/src/parser.ts | 5 ++ .../tests/fixtures/moduleResolver/file.ts | 0 .../fixtures/moduleResolver/moduleResolver.js | 36 ++++++++ .../fixtures/moduleResolver/something.ts | 1 + .../tsconfig.defaultProgram.json | 3 + .../fixtures/moduleResolver/tsconfig.json | 3 + .../typescript-estree/tests/lib/parse.test.ts | 90 +++++++++++++++++++ 14 files changed, 220 insertions(+), 5 deletions(-) create mode 100644 packages/typescript-estree/tests/fixtures/moduleResolver/file.ts create mode 100644 packages/typescript-estree/tests/fixtures/moduleResolver/moduleResolver.js create mode 100644 packages/typescript-estree/tests/fixtures/moduleResolver/something.ts create mode 100644 packages/typescript-estree/tests/fixtures/moduleResolver/tsconfig.defaultProgram.json create mode 100644 packages/typescript-estree/tests/fixtures/moduleResolver/tsconfig.json diff --git a/packages/parser/README.md b/packages/parser/README.md index 04f5c53d0f4..70ca5bf63d6 100644 --- a/packages/parser/README.md +++ b/packages/parser/README.md @@ -66,6 +66,7 @@ interface ParserOptions { warnOnUnsupportedTypeScriptVersion?: boolean; program?: import('typescript').Program; + moduleResolver?: string; } ``` @@ -221,6 +222,29 @@ This option allows you to programmatically provide an array of one or more insta This will override any programs that would have been computed from `parserOptions.project` or `parserOptions.createDefaultProgram`. All linted files must be part of the provided program(s). +### `parserOptions.moduleResolver` + +Default `undefined`. + +This option allows you to provide a custom module resolution. The value should point to a JS file that default exports (`export default`, or `module.exports =`, or `export =`) a file with the following interface: + +```ts +interface ModuleResolver { + version: 1; + resolveModuleNames( + moduleNames: string[], + containingFile: string, + reusedNames: string[] | undefined, + redirectedReference: ts.ResolvedProjectReference | undefined, + options: ts.CompilerOptions, + ): (ts.ResolvedModule | undefined)[]; +} +``` + +[Refer to the TypeScript Wiki for an example on how to write the `resolveModuleNames` function](https://github.com/Microsoft/TypeScript/wiki/Using-the-Compiler-API#customizing-module-resolution). + +Note that if you pass custom programs via `options.programs` this option will not have any effect over them (you can simply add the custom resolution on them directly). + ## Utilities ### `createProgram(configFile, projectDirectory)` diff --git a/packages/types/src/parser-options.ts b/packages/types/src/parser-options.ts index a8ecb896726..850086e1c5b 100644 --- a/packages/types/src/parser-options.ts +++ b/packages/types/src/parser-options.ts @@ -51,6 +51,7 @@ interface ParserOptions { tsconfigRootDir?: string; useJSXTextNode?: boolean; warnOnUnsupportedTypeScriptVersion?: boolean; + moduleResolver?: string; } export { DebugLevel, EcmaVersion, ParserOptions, SourceType }; diff --git a/packages/typescript-estree/README.md b/packages/typescript-estree/README.md index 31084150646..9cbcb2727cf 100644 --- a/packages/typescript-estree/README.md +++ b/packages/typescript-estree/README.md @@ -239,6 +239,11 @@ interface ParseAndGenerateServicesOptions extends ParseOptions { * whether or not ESLint is being used as part of a single run. */ allowAutomaticSingleRunInference?: boolean; + + /** + * Path to a file exporting a custom ModuleResolver. + */ + moduleResolver?: string; } interface ParserServices { diff --git a/packages/typescript-estree/src/create-program/createDefaultProgram.ts b/packages/typescript-estree/src/create-program/createDefaultProgram.ts index c912a44418a..6e8c2161250 100644 --- a/packages/typescript-estree/src/create-program/createDefaultProgram.ts +++ b/packages/typescript-estree/src/create-program/createDefaultProgram.ts @@ -6,6 +6,7 @@ import { ASTAndProgram, CanonicalPath, createDefaultCompilerOptionsFromExtra, + getModuleResolver, } from './shared'; const log = debug('typescript-eslint:typescript-estree:createDefaultProgram'); @@ -43,6 +44,13 @@ function createDefaultProgram( commandLine.options, /* setParentNodes */ true, ); + + if (extra.moduleResolver) { + compilerHost.resolveModuleNames = getModuleResolver( + extra.moduleResolver, + ).resolveModuleNames; + } + const oldReadFile = compilerHost.readFile; compilerHost.readFile = (fileName: string): string | undefined => path.normalize(fileName) === path.normalize(extra.filePath) diff --git a/packages/typescript-estree/src/create-program/createWatchProgram.ts b/packages/typescript-estree/src/create-program/createWatchProgram.ts index f10f2a2295f..2142065e47b 100644 --- a/packages/typescript-estree/src/create-program/createWatchProgram.ts +++ b/packages/typescript-estree/src/create-program/createWatchProgram.ts @@ -9,6 +9,7 @@ import { CanonicalPath, createDefaultCompilerOptionsFromExtra, getCanonicalFileName, + getModuleResolver, } from './shared'; const log = debug('typescript-eslint:typescript-estree:createWatchProgram'); @@ -269,6 +270,12 @@ function createWatchProgram( /*reportWatchStatus*/ () => {}, ) as WatchCompilerHostOfConfigFile; + if (extra.moduleResolver) { + watchCompilerHost.resolveModuleNames = getModuleResolver( + extra.moduleResolver, + ).resolveModuleNames; + } + // ensure readFile reads the code being linted instead of the copy on disk const oldReadFile = watchCompilerHost.readFile; watchCompilerHost.readFile = (filePathIn, encoding): string | undefined => { diff --git a/packages/typescript-estree/src/create-program/shared.ts b/packages/typescript-estree/src/create-program/shared.ts index 8202c538d34..a0e654c9097 100644 --- a/packages/typescript-estree/src/create-program/shared.ts +++ b/packages/typescript-estree/src/create-program/shared.ts @@ -1,7 +1,7 @@ import path from 'path'; import * as ts from 'typescript'; import { Program } from 'typescript'; -import { Extra } from '../parser-options'; +import { Extra, ModuleResolver } from '../parser-options'; interface ASTAndProgram { ast: ts.SourceFile; @@ -124,6 +124,23 @@ function getAstFromProgram( return ast && { ast, program: currentProgram }; } +function getModuleResolver(moduleResolverPath: string): ModuleResolver { + let moduleResolver: ModuleResolver; + + try { + moduleResolver = require(moduleResolverPath) as ModuleResolver; + } catch (error) { + const errorLines = [ + 'Could not find the provided parserOptions.moduleResolver.', + 'Hint: use an absolute path if you are not in control over where the ESLint instance runs.', + ]; + + throw new Error(errorLines.join('\n')); + } + + return moduleResolver; +} + export { ASTAndProgram, CORE_COMPILER_OPTIONS, @@ -134,4 +151,5 @@ export { getCanonicalFileName, getScriptKind, getAstFromProgram, + getModuleResolver, }; diff --git a/packages/typescript-estree/src/parser-options.ts b/packages/typescript-estree/src/parser-options.ts index 0bde1d9176a..55f47ef9041 100644 --- a/packages/typescript-estree/src/parser-options.ts +++ b/packages/typescript-estree/src/parser-options.ts @@ -1,5 +1,5 @@ import { DebugLevel } from '@typescript-eslint/types'; -import type { Program } from 'typescript'; +import * as ts from 'typescript'; import { CanonicalPath } from './create-program/shared'; import { TSESTree, TSESTreeToTSNode, TSNode, TSToken } from './ts-estree'; @@ -21,13 +21,14 @@ export interface Extra { singleRun: boolean; log: (message: string) => void; preserveNodeMaps?: boolean; - programs: null | Iterable; + programs: null | Iterable; projects: CanonicalPath[]; range: boolean; strict: boolean; tokens: null | TSESTree.Token[]; tsconfigRootDir: string; useJSXTextNode: boolean; + moduleResolver: string; } //////////////////////////////////////////////////// @@ -176,7 +177,7 @@ interface ParseAndGenerateServicesOptions extends ParseOptions { * This overrides any program or programs that would have been computed from the `project` option. * All linted files must be part of the provided program(s). */ - programs?: Program[]; + programs?: ts.Program[]; /** *************************************************************************************** @@ -202,6 +203,8 @@ interface ParseAndGenerateServicesOptions extends ParseOptions { * whether or not ESLint is being used as part of a single run. */ allowAutomaticSingleRunInference?: boolean; + + moduleResolver?: string; } export type TSESTreeOptions = ParseAndGenerateServicesOptions; @@ -221,8 +224,19 @@ export interface ParserWeakMapESTreeToTSNode< } export interface ParserServices { - program: Program; + program: ts.Program; esTreeNodeToTSNodeMap: ParserWeakMapESTreeToTSNode; tsNodeToESTreeNodeMap: ParserWeakMap; hasFullTypeInformation: boolean; } + +export interface ModuleResolver { + version: 1; + resolveModuleNames( + moduleNames: string[], + containingFile: string, + reusedNames: string[] | undefined, + redirectedReference: ts.ResolvedProjectReference | undefined, + options: ts.CompilerOptions, + ): (ts.ResolvedModule | undefined)[]; +} diff --git a/packages/typescript-estree/src/parser.ts b/packages/typescript-estree/src/parser.ts index 904bb054fdf..3701a8862b3 100644 --- a/packages/typescript-estree/src/parser.ts +++ b/packages/typescript-estree/src/parser.ts @@ -135,6 +135,7 @@ function resetExtra(): void { * of a long-running session (e.g. in an IDE) and watch programs will therefore be required */ singleRun: false, + moduleResolver: '', }; } @@ -342,6 +343,10 @@ function applyParserOptionsToExtra(options: TSESTreeOptions): void { extra.EXPERIMENTAL_useSourceOfProjectReferenceRedirect = typeof options.EXPERIMENTAL_useSourceOfProjectReferenceRedirect === 'boolean' && options.EXPERIMENTAL_useSourceOfProjectReferenceRedirect; + + if (typeof options.moduleResolver === 'string') { + extra.moduleResolver = options.moduleResolver; + } } function warnAboutTSVersion(): void { diff --git a/packages/typescript-estree/tests/fixtures/moduleResolver/file.ts b/packages/typescript-estree/tests/fixtures/moduleResolver/file.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/typescript-estree/tests/fixtures/moduleResolver/moduleResolver.js b/packages/typescript-estree/tests/fixtures/moduleResolver/moduleResolver.js new file mode 100644 index 00000000000..112df1b4b5f --- /dev/null +++ b/packages/typescript-estree/tests/fixtures/moduleResolver/moduleResolver.js @@ -0,0 +1,36 @@ +const ts = require('typescript'); + +module.exports = { + version: 1, + resolveModuleNames: ( + moduleNames, + containingFile, + _reusedNames, + _redirectedReferences, + compilerOptions, + ) => { + const resolvedModules = []; + + for (const moduleName of moduleNames) { + let parsedModuleName = moduleName; + + if (parsedModuleName === '__PLACEHOLDER__') { + parsedModuleName = './something'; + } + + const resolution = ts.resolveModuleName( + parsedModuleName, + containingFile, + compilerOptions, + { + fileExists: ts.sys.fileExists, + readFile: ts.sys.readFile, + }, + ); + + resolvedModules.push(resolution.resolvedModule); + } + + return resolvedModules; + } +} diff --git a/packages/typescript-estree/tests/fixtures/moduleResolver/something.ts b/packages/typescript-estree/tests/fixtures/moduleResolver/something.ts new file mode 100644 index 00000000000..c069798b6c2 --- /dev/null +++ b/packages/typescript-estree/tests/fixtures/moduleResolver/something.ts @@ -0,0 +1 @@ +export const something = () => true; diff --git a/packages/typescript-estree/tests/fixtures/moduleResolver/tsconfig.defaultProgram.json b/packages/typescript-estree/tests/fixtures/moduleResolver/tsconfig.defaultProgram.json new file mode 100644 index 00000000000..8de5ab30398 --- /dev/null +++ b/packages/typescript-estree/tests/fixtures/moduleResolver/tsconfig.defaultProgram.json @@ -0,0 +1,3 @@ +{ + "include": [] +} diff --git a/packages/typescript-estree/tests/fixtures/moduleResolver/tsconfig.json b/packages/typescript-estree/tests/fixtures/moduleResolver/tsconfig.json new file mode 100644 index 00000000000..e7f769a1841 --- /dev/null +++ b/packages/typescript-estree/tests/fixtures/moduleResolver/tsconfig.json @@ -0,0 +1,3 @@ +{ + "include": ["./file.ts", "./something.ts"] +} diff --git a/packages/typescript-estree/tests/lib/parse.test.ts b/packages/typescript-estree/tests/lib/parse.test.ts index 3e312973700..aa15fc136c1 100644 --- a/packages/typescript-estree/tests/lib/parse.test.ts +++ b/packages/typescript-estree/tests/lib/parse.test.ts @@ -670,4 +670,94 @@ describe('parseAndGenerateServices', () => { expect(testParse('includeme', ignore)).not.toThrow(); }); }); + + describe('moduleResolver', () => { + beforeEach(() => { + parser.clearCaches(); + }); + + const PROJECT_DIR = resolve(FIXTURES_DIR, '../moduleResolver'); + const code = ` + import { something } from '__PLACEHOLDER__'; + + something(); + `; + const config: TSESTreeOptions = { + comment: true, + tokens: true, + range: true, + loc: true, + project: './tsconfig.json', + tsconfigRootDir: PROJECT_DIR, + filePath: resolve(PROJECT_DIR, 'file.ts'), + }; + const withDefaultProgramConfig: TSESTreeOptions = { + ...config, + project: './tsconfig.defaultProgram.json', + createDefaultProgram: true, + }; + + describe('when file is in the project', () => { + it('returns error if __PLACEHOLDER__ can not be resolved', () => { + expect( + parser + .parseAndGenerateServices(code, config) + .services.program.getSemanticDiagnostics(), + ).toHaveProperty( + [0, 'messageText'], + "Cannot find module '__PLACEHOLDER__' or its corresponding type declarations.", + ); + }); + + it('throws error if moduleResolver can not be found', () => { + expect(() => + parser.parseAndGenerateServices(code, { + ...config, + moduleResolver: resolve( + PROJECT_DIR, + './this_moduleResolver_does_not_exist.js', + ), + }), + ).toThrowErrorMatchingInlineSnapshot(` + "Could not find the provided parserOptions.moduleResolver. + Hint: use an absolute path if you are not in control over where the ESLint instance runs." + `); + }); + + it('resolves __PLACEHOLDER__ correctly', () => { + expect( + parser + .parseAndGenerateServices(code, { + ...config, + moduleResolver: resolve(PROJECT_DIR, './moduleResolver.js'), + }) + .services.program.getSemanticDiagnostics(), + ).toHaveLength(0); + }); + }); + + describe('when file is not in the project and createDefaultProgram=true', () => { + it('returns error because __PLACEHOLDER__ can not be resolved', () => { + expect( + parser + .parseAndGenerateServices(code, withDefaultProgramConfig) + .services.program.getSemanticDiagnostics(), + ).toHaveProperty( + [0, 'messageText'], + "Cannot find module '__PLACEHOLDER__' or its corresponding type declarations.", + ); + }); + + it('resolves __PLACEHOLDER__ correctly', () => { + expect( + parser + .parseAndGenerateServices(code, { + ...withDefaultProgramConfig, + moduleResolver: resolve(PROJECT_DIR, './moduleResolver.js'), + }) + .services.program.getSemanticDiagnostics(), + ).toHaveLength(0); + }); + }); + }); });