Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(typescript-estree): add support for custom module resolution #3516

Merged
merged 3 commits into from Jul 31, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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);
});
});
});
});