Skip to content

Commit

Permalink
feat: allow user to provide TS program instance in parser options (#3484
Browse files Browse the repository at this point in the history
)
  • Loading branch information
uniqueiniquity committed Jun 8, 2021
1 parent 9c78619 commit e855b18
Show file tree
Hide file tree
Showing 13 changed files with 330 additions and 73 deletions.
35 changes: 35 additions & 0 deletions packages/parser/README.md
Expand Up @@ -64,6 +64,8 @@ interface ParserOptions {
tsconfigRootDir?: string;
extraFileExtensions?: string[];
warnOnUnsupportedTypeScriptVersion?: boolean;

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

Expand Down Expand Up @@ -211,6 +213,39 @@ Default `false`.

This option allows you to request that when the `project` setting is specified, files will be allowed when not included in the projects defined by the provided `tsconfig.json` files. **Using this option will incur significant performance costs. This option is primarily included for backwards-compatibility.** See the **`project`** section above for more information.

### `parserOptions.program`

Default `undefined`.

This option allows you to programmatically provide an instance of a TypeScript Program object that will provide type information to rules.
This will override any program that would have been computed from `parserOptions.project` or `parserOptions.createDefaultProgram`.
All linted files must be part of the provided program.

## Utilities

### `createProgram(configFile, projectDirectory)`

This serves as a utility method for users of the `parserOptions.program` feature to create a TypeScript program instance from a config file.

```ts
declare function createProgram(
configFile: string,
projectDirectory?: string,
): import('typescript').Program;
```

Example usage in .eslintrc.js:

```js
const parser = require('@typescript-eslint/parser');
const program = parser.createProgram('tsconfig.json');
module.exports = {
parserOptions: {
program,
},
};
```

## Supported TypeScript Version

Please see [`typescript-eslint`](https://github.com/typescript-eslint/typescript-eslint) for the supported TypeScript version.
Expand Down
1 change: 1 addition & 0 deletions packages/parser/src/index.ts
Expand Up @@ -2,6 +2,7 @@ export { parse, parseForESLint, ParserOptions } from './parser';
export {
ParserServices,
clearCaches,
createProgram,
} from '@typescript-eslint/typescript-estree';

// note - cannot migrate this to an import statement because it will make TSC copy the package.json to the dist folder
Expand Down
13 changes: 8 additions & 5 deletions packages/parser/tests/lib/services.ts
Expand Up @@ -7,6 +7,7 @@ import {
formatSnapshotName,
testServices,
} from '../tools/test-utils';
import { createProgram } from '@typescript-eslint/typescript-estree';

//------------------------------------------------------------------------------
// Setup
Expand All @@ -30,15 +31,17 @@ function createConfig(filename: string): ParserOptions {
//------------------------------------------------------------------------------

describe('services', () => {
const program = createProgram(path.resolve(FIXTURES_DIR, 'tsconfig.json'));
testFiles.forEach(filename => {
const code = fs.readFileSync(path.join(FIXTURES_DIR, filename), 'utf8');
const config = createConfig(filename);
it(
formatSnapshotName(filename, FIXTURES_DIR, '.ts'),
createSnapshotTestBlock(code, config),
);
it(`${formatSnapshotName(filename, FIXTURES_DIR, '.ts')} services`, () => {
const snapshotName = formatSnapshotName(filename, FIXTURES_DIR, '.ts');
it(snapshotName, createSnapshotTestBlock(code, config));
it(`${snapshotName} services`, () => {
testServices(code, config);
});
it(`${snapshotName} services with provided program`, () => {
testServices(code, { ...config, program });
});
});
});
3 changes: 3 additions & 0 deletions packages/types/package.json
Expand Up @@ -48,5 +48,8 @@
"_ts3.4/*"
]
}
},
"devDependencies": {
"typescript": "*"
}
}
2 changes: 2 additions & 0 deletions packages/types/src/parser-options.ts
@@ -1,4 +1,5 @@
import { Lib } from './lib';
import type { Program } from 'typescript';

type DebugLevel = boolean | ('typescript-eslint' | 'eslint' | 'typescript')[];

Expand Down Expand Up @@ -41,6 +42,7 @@ interface ParserOptions {
extraFileExtensions?: string[];
filePath?: string;
loc?: boolean;
program?: Program;
project?: string | string[];
projectFolderIgnoreList?: (string | RegExp)[];
range?: boolean;
Expand Down
35 changes: 35 additions & 0 deletions packages/typescript-estree/README.md
Expand Up @@ -208,6 +208,13 @@ interface ParseAndGenerateServicesOptions extends ParseOptions {
*/
tsconfigRootDir?: string;

/**
* Instance of a TypeScript Program object to be used for type information.
* 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.
*/
program?: import('typescript').Program;

/**
***************************************************************************************
* IT IS RECOMMENDED THAT YOU DO NOT USE THIS OPTION, AS IT CAUSES PERFORMANCE ISSUES. *
Expand Down Expand Up @@ -303,6 +310,34 @@ Types for the AST produced by the parse functions.
- `AST_NODE_TYPES` is an enum which provides the values for every single AST node's `type` property.
- `AST_TOKEN_TYPES` is an enum which provides the values for every single AST token's `type` property.

### Utilities

#### `createProgram(configFile, projectDirectory)`

This serves as a utility method for users of the `ParseOptions.program` feature to create a TypeScript program instance from a config file.

```ts
declare function createProgram(
configFile: string,
projectDirectory: string = process.cwd(),
): import('typescript').Program;
```

Example usage:

```js
const tsESTree = require('@typescript-eslint/typescript-estree');

const program = tsESTree.createProgram('tsconfig.json');
const code = `const hello: string = 'world';`;
const { ast, services } = parseAndGenerateServices(code, {
filePath: '/some/path/to/file/foo.ts',
loc: true,
program,
range: true,
});
```

## Supported TypeScript Version

See the [Supported TypeScript Version](../../README.md#supported-typescript-version) section in the project root.
Expand Down
Expand Up @@ -3,19 +3,12 @@ import path from 'path';
import { getProgramsForProjects } from './createWatchProgram';
import { firstDefined } from '../node-utils';
import { Extra } from '../parser-options';
import { ASTAndProgram } from './shared';
import { ASTAndProgram, getAstFromProgram } from './shared';

const log = debug('typescript-eslint:typescript-estree:createProjectProgram');

const DEFAULT_EXTRA_FILE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx'];

function getExtension(fileName: string | undefined): string | null {
if (!fileName) {
return null;
}
return fileName.endsWith('.d.ts') ? '.d.ts' : path.extname(fileName);
}

/**
* @param code The code of the file being linted
* @param createDefaultProgram True if the default program should be created
Expand All @@ -31,18 +24,7 @@ function createProjectProgram(

const astAndProgram = firstDefined(
getProgramsForProjects(code, extra.filePath, extra),
currentProgram => {
const ast = currentProgram.getSourceFile(extra.filePath);

// working around https://github.com/typescript-eslint/typescript-eslint/issues/1573
const expectedExt = getExtension(extra.filePath);
const returnedExt = getExtension(ast?.fileName);
if (expectedExt !== returnedExt) {
return;
}

return ast && { ast, program: currentProgram };
},
currentProgram => getAstFromProgram(currentProgram, extra),
);

if (!astAndProgram && !createDefaultProgram) {
Expand Down
47 changes: 40 additions & 7 deletions packages/typescript-estree/src/create-program/shared.ts
@@ -1,5 +1,6 @@
import path from 'path';
import * as ts from 'typescript';
import { Program } from 'typescript';
import { Extra } from '../parser-options';

interface ASTAndProgram {
Expand All @@ -8,21 +9,28 @@ interface ASTAndProgram {
}

/**
* Default compiler options for program generation from single root file
* Compiler options required to avoid critical functionality issues
*/
const DEFAULT_COMPILER_OPTIONS: ts.CompilerOptions = {
allowNonTsExtensions: true,
allowJs: true,
checkJs: true,
noEmit: true,
// extendedDiagnostics: true,
const CORE_COMPILER_OPTIONS: ts.CompilerOptions = {
noEmit: true, // required to avoid parse from causing emit to occur

/**
* Flags required to make no-unused-vars work
*/
noUnusedLocals: true,
noUnusedParameters: true,
};

/**
* Default compiler options for program generation
*/
const DEFAULT_COMPILER_OPTIONS: ts.CompilerOptions = {
...CORE_COMPILER_OPTIONS,
allowNonTsExtensions: true,
allowJs: true,
checkJs: true,
};

function createDefaultCompilerOptionsFromExtra(
extra: Extra,
): ts.CompilerOptions {
Expand Down Expand Up @@ -93,12 +101,37 @@ function getScriptKind(
}
}

function getExtension(fileName: string | undefined): string | null {
if (!fileName) {
return null;
}
return fileName.endsWith('.d.ts') ? '.d.ts' : path.extname(fileName);
}

function getAstFromProgram(
currentProgram: Program,
extra: Extra,
): ASTAndProgram | undefined {
const ast = currentProgram.getSourceFile(extra.filePath);

// working around https://github.com/typescript-eslint/typescript-eslint/issues/1573
const expectedExt = getExtension(extra.filePath);
const returnedExt = getExtension(ast?.fileName);
if (expectedExt !== returnedExt) {
return undefined;
}

return ast && { ast, program: currentProgram };
}

export {
ASTAndProgram,
CORE_COMPILER_OPTIONS,
canonicalDirname,
CanonicalPath,
createDefaultCompilerOptionsFromExtra,
ensureAbsolutePath,
getCanonicalFileName,
getScriptKind,
getAstFromProgram,
};
@@ -0,0 +1,87 @@
import debug from 'debug';
import * as fs from 'fs';
import * as path from 'path';
import * as ts from 'typescript';
import { Extra } from '../parser-options';
import {
ASTAndProgram,
CORE_COMPILER_OPTIONS,
getAstFromProgram,
} from './shared';

const log = debug('typescript-eslint:typescript-estree:useProvidedProgram');

function useProvidedProgram(
programInstance: ts.Program,
extra: Extra,
): ASTAndProgram | undefined {
log('Retrieving ast for %s from provided program instance', extra.filePath);

programInstance.getTypeChecker(); // ensure parent pointers are set in source files

const astAndProgram = getAstFromProgram(programInstance, extra);

if (!astAndProgram) {
const relativeFilePath = path.relative(
extra.tsconfigRootDir || process.cwd(),
extra.filePath,
);
const errorLines = [
'"parserOptions.program" has been provided for @typescript-eslint/parser.',
`The file was not found in the provided program instance: ${relativeFilePath}`,
];

throw new Error(errorLines.join('\n'));
}

return astAndProgram;
}

/**
* Utility offered by parser to help consumers construct their own program instance.
*
* @param configFile the path to the tsconfig.json file, relative to `projectDirectory`
* @param projectDirectory the project directory to use as the CWD, defaults to `process.cwd()`
*/
function createProgramFromConfigFile(
configFile: string,
projectDirectory?: string,
): ts.Program {
if (ts.sys === undefined) {
throw new Error(
'`createProgramFromConfigFile` is only supported in a Node-like environment.',
);
}

const parsed = ts.getParsedCommandLineOfConfigFile(
configFile,
CORE_COMPILER_OPTIONS,
{
onUnRecoverableConfigFileDiagnostic: diag => {
throw new Error(formatDiagnostics([diag])); // ensures that `parsed` is defined.
},
fileExists: fs.existsSync,
getCurrentDirectory: () =>
(projectDirectory && path.resolve(projectDirectory)) || process.cwd(),
readDirectory: ts.sys.readDirectory,
readFile: file => fs.readFileSync(file, 'utf-8'),
useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames,
},
);
const result = parsed!; // parsed is not undefined, since we throw on failure.
if (result.errors.length) {
throw new Error(formatDiagnostics(result.errors));
}
const host = ts.createCompilerHost(result.options, true);
return ts.createProgram(result.fileNames, result.options, host);
}

function formatDiagnostics(diagnostics: ts.Diagnostic[]): string | undefined {
return ts.formatDiagnostics(diagnostics, {
getCanonicalFileName: f => f,
getCurrentDirectory: process.cwd,
getNewLine: () => '\n',
});
}

export { useProvidedProgram, createProgramFromConfigFile };
1 change: 1 addition & 0 deletions packages/typescript-estree/src/index.ts
Expand Up @@ -3,6 +3,7 @@ export { ParserServices, TSESTreeOptions } from './parser-options';
export { simpleTraverse } from './simple-traverse';
export * from './ts-estree';
export { clearCaches } from './create-program/createWatchProgram';
export { createProgramFromConfigFile as createProgram } from './create-program/useProvidedProgram';

// re-export for backwards-compat
export { visitorKeys } from '@typescript-eslint/visitor-keys';
Expand Down
8 changes: 8 additions & 0 deletions packages/typescript-estree/src/parser-options.ts
Expand Up @@ -20,6 +20,7 @@ export interface Extra {
loc: boolean;
log: (message: string) => void;
preserveNodeMaps?: boolean;
program: null | Program;
projects: CanonicalPath[];
range: boolean;
strict: boolean;
Expand Down Expand Up @@ -169,6 +170,13 @@ interface ParseAndGenerateServicesOptions extends ParseOptions {
*/
tsconfigRootDir?: string;

/**
* Instance of a TypeScript Program object to be used for type information.
* 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.
*/
program?: Program;

/**
***************************************************************************************
* IT IS RECOMMENDED THAT YOU DO NOT USE THIS OPTION, AS IT CAUSES PERFORMANCE ISSUES. *
Expand Down

0 comments on commit e855b18

Please sign in to comment.