Skip to content

Commit

Permalink
feat(typescript-estree): add support for custom module resolution (#3516
Browse files Browse the repository at this point in the history
)
  • Loading branch information
rdsedmundo committed Jul 31, 2021
1 parent 50055ec commit d48429d
Show file tree
Hide file tree
Showing 14 changed files with 220 additions and 5 deletions.
24 changes: 24 additions & 0 deletions packages/parser/README.md
Expand Up @@ -66,6 +66,7 @@ interface ParserOptions {
warnOnUnsupportedTypeScriptVersion?: boolean;

program?: import('typescript').Program;
moduleResolver?: string;
}
```

Expand Down Expand Up @@ -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)`
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/parser-options.ts
Expand Up @@ -51,6 +51,7 @@ interface ParserOptions {
tsconfigRootDir?: string;
useJSXTextNode?: boolean;
warnOnUnsupportedTypeScriptVersion?: boolean;
moduleResolver?: string;
}

export { DebugLevel, EcmaVersion, ParserOptions, SourceType };
5 changes: 5 additions & 0 deletions packages/typescript-estree/README.md
Expand Up @@ -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 {
Expand Down
Expand Up @@ -6,6 +6,7 @@ import {
ASTAndProgram,
CanonicalPath,
createDefaultCompilerOptionsFromExtra,
getModuleResolver,
} from './shared';

const log = debug('typescript-eslint:typescript-estree:createDefaultProgram');
Expand Down Expand Up @@ -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)
Expand Down
Expand Up @@ -9,6 +9,7 @@ import {
CanonicalPath,
createDefaultCompilerOptionsFromExtra,
getCanonicalFileName,
getModuleResolver,
} from './shared';

const log = debug('typescript-eslint:typescript-estree:createWatchProgram');
Expand Down Expand Up @@ -269,6 +270,12 @@ function createWatchProgram(
/*reportWatchStatus*/ () => {},
) as WatchCompilerHostOfConfigFile<ts.BuilderProgram>;

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 => {
Expand Down
20 changes: 19 additions & 1 deletion 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;
Expand Down Expand Up @@ -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,
Expand All @@ -134,4 +151,5 @@ export {
getCanonicalFileName,
getScriptKind,
getAstFromProgram,
getModuleResolver,
};
22 changes: 18 additions & 4 deletions 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';

Expand All @@ -21,13 +21,14 @@ export interface Extra {
singleRun: boolean;
log: (message: string) => void;
preserveNodeMaps?: boolean;
programs: null | Iterable<Program>;
programs: null | Iterable<ts.Program>;
projects: CanonicalPath[];
range: boolean;
strict: boolean;
tokens: null | TSESTree.Token[];
tsconfigRootDir: string;
useJSXTextNode: boolean;
moduleResolver: string;
}

////////////////////////////////////////////////////
Expand Down Expand Up @@ -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[];

/**
***************************************************************************************
Expand All @@ -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;
Expand All @@ -221,8 +224,19 @@ export interface ParserWeakMapESTreeToTSNode<
}

export interface ParserServices {
program: Program;
program: ts.Program;
esTreeNodeToTSNodeMap: ParserWeakMapESTreeToTSNode;
tsNodeToESTreeNodeMap: ParserWeakMap<TSNode | TSToken, TSESTree.Node>;
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)[];
}
5 changes: 5 additions & 0 deletions packages/typescript-estree/src/parser.ts
Expand Up @@ -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: '',
};
}

Expand Down Expand Up @@ -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 {
Expand Down
Empty file.
@@ -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;
}
}
@@ -0,0 +1 @@
export const something = () => true;
@@ -0,0 +1,3 @@
{
"include": []
}
@@ -0,0 +1,3 @@
{
"include": ["./file.ts", "./something.ts"]
}
90 changes: 90 additions & 0 deletions packages/typescript-estree/tests/lib/parse.test.ts
Expand Up @@ -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);
});
});
});
});

0 comments on commit d48429d

Please sign in to comment.