Skip to content

Commit

Permalink
feat(typescript-estree): add allowDefaultProjectForFiles project serv…
Browse files Browse the repository at this point in the history
…ice allowlist option (#7752)

* fix(typescript-estree): allow project service for unknown client file

* Fixed up project throwing tests

* Added allowDefaultProjectForFiles as nested option

* Added a bit more testing

* fix: only throw on missing file path if hasFullTypeInformation

* Fix test snapshot

* Remove unnecessary assertion

* TypeScript_ESTree.mdx docs

* Absolute and canonical file paths

* Use minimatch instead of fs globbing

* lint: import sorting, missing return type

* Ignore some tests
  • Loading branch information
JoshuaKGoldberg committed Dec 24, 2023
1 parent 86cdb77 commit 7ddadda
Show file tree
Hide file tree
Showing 12 changed files with 203 additions and 105 deletions.
12 changes: 11 additions & 1 deletion docs/packages/TypeScript_ESTree.mdx
Expand Up @@ -167,7 +167,7 @@ interface ParseAndGenerateServicesOptions extends ParseOptions {
*
* @see https://github.com/typescript-eslint/typescript-eslint/issues/6575
*/
EXPERIMENTAL_useProjectService?: boolean;
EXPERIMENTAL_useProjectService?: boolean | ProjectServiceOptions;

/**
* ***EXPERIMENTAL FLAG*** - Use this at your own risk.
Expand Down Expand Up @@ -270,6 +270,16 @@ interface ParseAndGenerateServicesOptions extends ParseOptions {
};
}

/**
* Granular options to configure the project service.
*/
interface ProjectServiceOptions {
/**
* Globs of files to allow running with the default inferred project settings.
*/
allowDefaultProjectForFiles?: string[];
}

interface ParserServices {
program: ts.Program;
esTreeNodeToTSNodeMap: WeakMap<TSESTree.Node, ts.Node | ts.Token>;
Expand Down
126 changes: 64 additions & 62 deletions packages/eslint-plugin-tslint/tests/index.spec.ts
Expand Up @@ -166,71 +166,73 @@ ruleTester.run('tslint/config', rule, {
],
});

describe('tslint/error', () => {
function testOutput(code: string, config: ClassicConfig.Config): void {
const linter = new TSESLint.Linter();
linter.defineRule('tslint/config', rule);
linter.defineParser('@typescript-eslint/parser', parser);

expect(() => linter.verify(code, config)).toThrow(
'You have used a rule which requires parserServices to be generated. You must therefore provide a value for the "parserOptions.project" property for @typescript-eslint/parser.',
);
}

it('should error on missing project', () => {
testOutput('foo;', {
rules: {
'tslint/config': [2, tslintRulesConfig],
},
parser: '@typescript-eslint/parser',
if (process.env.TYPESCRIPT_ESLINT_EXPERIMENTAL_TSSERVER !== 'true') {
describe('tslint/error', () => {
function testOutput(code: string, config: ClassicConfig.Config): void {
const linter = new TSESLint.Linter();
linter.defineRule('tslint/config', rule);
linter.defineParser('@typescript-eslint/parser', parser);

expect(() => linter.verify(code, config)).toThrow(
'You have used a rule which requires parserServices to be generated. You must therefore provide a value for the "parserOptions.project" property for @typescript-eslint/parser.',
);
}

it('should error on missing project', () => {
testOutput('foo;', {
rules: {
'tslint/config': [2, tslintRulesConfig],
},
parser: '@typescript-eslint/parser',
});
});
});

it('should error on default parser', () => {
testOutput('foo;', {
parserOptions: {
project: TEST_PROJECT_PATH,
},
rules: {
'tslint/config': [2, tslintRulesConfig],
},
it('should error on default parser', () => {
testOutput('foo;', {
parserOptions: {
project: TEST_PROJECT_PATH,
},
rules: {
'tslint/config': [2, tslintRulesConfig],
},
});
});
});

it('should not crash if there are no tslint rules specified', () => {
const linter = new TSESLint.Linter();
jest.spyOn(console, 'warn').mockImplementation();
linter.defineRule('tslint/config', rule);
linter.defineParser('@typescript-eslint/parser', parser);

const filePath = path.resolve(
__dirname,
'fixtures',
'test-project',
'extra.ts',
);

expect(() =>
linter.verify(
'foo;',
{
parserOptions: {
project: TEST_PROJECT_PATH,
},
rules: {
'tslint/config': [2, {}],
it('should not crash if there are no tslint rules specified', () => {
const linter = new TSESLint.Linter();
jest.spyOn(console, 'warn').mockImplementation();
linter.defineRule('tslint/config', rule);
linter.defineParser('@typescript-eslint/parser', parser);

const filePath = path.resolve(
__dirname,
'fixtures',
'test-project',
'extra.ts',
);

expect(() =>
linter.verify(
'foo;',
{
parserOptions: {
project: TEST_PROJECT_PATH,
},
rules: {
'tslint/config': [2, {}],
},
parser: '@typescript-eslint/parser',
},
parser: '@typescript-eslint/parser',
},
filePath,
),
).not.toThrow();

expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining(
`Tried to lint ${filePath} but found no valid, enabled rules for this file type and file path in the resolved configuration.`,
),
);
jest.resetAllMocks();
filePath,
),
).not.toThrow();

expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining(
`Tried to lint ${filePath} but found no valid, enabled rules for this file type and file path in the resolved configuration.`,
),
);
jest.resetAllMocks();
});
});
});
}
1 change: 1 addition & 0 deletions packages/typescript-estree/package.json
Expand Up @@ -57,6 +57,7 @@
"debug": "^4.3.4",
"globby": "^11.1.0",
"is-glob": "^4.0.3",
"minimatch": "9.0.3",
"semver": "^7.5.4",
"ts-api-utils": "^1.0.1"
},
Expand Down
@@ -1,6 +1,8 @@
/* eslint-disable @typescript-eslint/no-empty-function -- for TypeScript APIs*/
import type * as ts from 'typescript/lib/tsserverlibrary';

// eslint-disable-next-line @typescript-eslint/no-empty-function
import type { ProjectServiceOptions } from '../parser-options';

const doNothing = (): void => {};

const createStubFileWatcher = (): ts.FileWatcher => ({
Expand All @@ -9,9 +11,15 @@ const createStubFileWatcher = (): ts.FileWatcher => ({

export type TypeScriptProjectService = ts.server.ProjectService;

export interface ProjectServiceSettings {
allowDefaultProjectForFiles: string[] | undefined;
service: TypeScriptProjectService;
}

export function createProjectService(
jsDocParsingMode?: ts.JSDocParsingMode,
): TypeScriptProjectService {
options: boolean | ProjectServiceOptions | undefined,
jsDocParsingMode: ts.JSDocParsingMode | undefined,
): ProjectServiceSettings {
// We import this lazily to avoid its cost for users who don't use the service
// TODO: Once we drop support for TS<5.3 we can import from "typescript" directly
const tsserver = require('typescript/lib/tsserverlibrary') as typeof ts;
Expand All @@ -30,7 +38,7 @@ export function createProjectService(
watchFile: createStubFileWatcher,
};

return new tsserver.server.ProjectService({
const service = new tsserver.server.ProjectService({
host: system,
cancellationToken: { isCancellationRequested: (): boolean => false },
useSingleInferredProject: false,
Expand All @@ -49,4 +57,12 @@ export function createProjectService(
session: undefined,
jsDocParsingMode,
});

return {
allowDefaultProjectForFiles:
typeof options === 'object'
? options.allowDefaultProjectForFiles
: undefined,
service,
};
}
@@ -1,7 +1,7 @@
import debug from 'debug';
import * as ts from 'typescript';

import type { TypeScriptProjectService } from '../create-program/createProjectService';
import type { ProjectServiceSettings } from '../create-program/createProjectService';
import { createProjectService } from '../create-program/createProjectService';
import { ensureAbsolutePath } from '../create-program/shared';
import type { TSESTreeOptions } from '../parser-options';
Expand All @@ -21,7 +21,7 @@ const log = debug(
);

let TSCONFIG_MATCH_CACHE: ExpiringCache<string, string> | null;
let TSSERVER_PROJECT_SERVICE: TypeScriptProjectService | null = null;
let TSSERVER_PROJECT_SERVICE: ProjectServiceSettings | null = null;

// NOTE - we intentionally use "unnecessary" `?.` here because in TS<5.3 this enum doesn't exist
// This object exists so we can centralize these for tracking and so we don't proliferate these across the file
Expand Down Expand Up @@ -80,11 +80,14 @@ export function createParseSettings(
errorOnTypeScriptSyntacticAndSemanticIssues: false,
errorOnUnknownASTType: options.errorOnUnknownASTType === true,
EXPERIMENTAL_projectService:
(options.EXPERIMENTAL_useProjectService === true &&
(options.EXPERIMENTAL_useProjectService &&
process.env.TYPESCRIPT_ESLINT_EXPERIMENTAL_TSSERVER !== 'false') ||
(process.env.TYPESCRIPT_ESLINT_EXPERIMENTAL_TSSERVER === 'true' &&
options.EXPERIMENTAL_useProjectService !== false)
? (TSSERVER_PROJECT_SERVICE ??= createProjectService(jsDocParsingMode))
? (TSSERVER_PROJECT_SERVICE ??= createProjectService(
options.EXPERIMENTAL_useProjectService,
jsDocParsingMode,
))
: undefined,
EXPERIMENTAL_useSourceOfProjectReferenceRedirect:
options.EXPERIMENTAL_useSourceOfProjectReferenceRedirect === true,
Expand Down
6 changes: 2 additions & 4 deletions packages/typescript-estree/src/parseSettings/index.ts
@@ -1,6 +1,6 @@
import type * as ts from 'typescript';
import type * as tsserverlibrary from 'typescript/lib/tsserverlibrary';

import type { ProjectServiceSettings } from '../create-program/createProjectService';
import type { CanonicalPath } from '../create-program/shared';
import type { TSESTree } from '../ts-estree';
import type { CacheLike } from './ExpiringCache';
Expand Down Expand Up @@ -67,9 +67,7 @@ export interface MutableParseSettings {
/**
* Experimental: TypeScript server to power program creation.
*/
EXPERIMENTAL_projectService:
| tsserverlibrary.server.ProjectService
| undefined;
EXPERIMENTAL_projectService: ProjectServiceSettings | undefined;

/**
* Whether TS should use the source files for referenced projects instead of the compiled .d.ts files.
Expand Down
12 changes: 11 additions & 1 deletion packages/typescript-estree/src/parser-options.ts
Expand Up @@ -101,6 +101,16 @@ interface ParseOptions {
suppressDeprecatedPropertyWarnings?: boolean;
}

/**
* Granular options to configure the project service.
*/
export interface ProjectServiceOptions {
/**
* Globs of files to allow running with the default inferred project settings.
*/
allowDefaultProjectForFiles?: string[];
}

interface ParseAndGenerateServicesOptions extends ParseOptions {
/**
* Causes the parser to error if the TypeScript compiler returns any unexpected syntax/semantic errors.
Expand All @@ -114,7 +124,7 @@ interface ParseAndGenerateServicesOptions extends ParseOptions {
*
* @see https://github.com/typescript-eslint/typescript-eslint/issues/6575
*/
EXPERIMENTAL_useProjectService?: boolean;
EXPERIMENTAL_useProjectService?: boolean | ProjectServiceOptions;

/**
* ***EXPERIMENTAL FLAG*** - Use this at your own risk.
Expand Down
1 change: 1 addition & 0 deletions packages/typescript-estree/src/parser.ts
Expand Up @@ -53,6 +53,7 @@ function getProgramAndAST(
const fromProjectService = useProgramFromProjectService(
parseSettings.EXPERIMENTAL_projectService,
parseSettings,
hasFullTypeInformation,
);
if (fromProjectService) {
return fromProjectService;
Expand Down
51 changes: 36 additions & 15 deletions packages/typescript-estree/src/useProgramFromProjectService.ts
@@ -1,26 +1,44 @@
import path from 'path';
import type { server } from 'typescript/lib/tsserverlibrary';
import { minimatch } from 'minimatch';

import { createProjectProgram } from './create-program/createProjectProgram';
import { type ASTAndDefiniteProgram } from './create-program/shared';
import type { ProjectServiceSettings } from './create-program/createProjectService';
import {
type ASTAndDefiniteProgram,
ensureAbsolutePath,
getCanonicalFileName,
} from './create-program/shared';
import type { MutableParseSettings } from './parseSettings';

export function useProgramFromProjectService(
projectService: server.ProjectService,
{ allowDefaultProjectForFiles, service }: ProjectServiceSettings,
parseSettings: Readonly<MutableParseSettings>,
hasFullTypeInformation: boolean,
): ASTAndDefiniteProgram | undefined {
const opened = projectService.openClientFile(
absolutify(parseSettings.filePath),
const filePath = getCanonicalFileName(parseSettings.filePath);

const opened = service.openClientFile(
ensureAbsolutePath(filePath, service.host.getCurrentDirectory()),
parseSettings.codeFullText,
/* scriptKind */ undefined,
parseSettings.tsconfigRootDir,
);
if (!opened.configFileName) {
return undefined;

if (hasFullTypeInformation) {
if (opened.configFileName) {
if (filePathMatchedBy(filePath, allowDefaultProjectForFiles)) {
throw new Error(
`${filePath} was included by allowDefaultProjectForFiles but also was found in the project service. Consider removing it from allowDefaultProjectForFiles.`,
);
}
} else if (!filePathMatchedBy(filePath, allowDefaultProjectForFiles)) {
throw new Error(
`${filePath} was not found by the project service. Consider either including it in the tsconfig.json or including it in allowDefaultProjectForFiles.`,
);
}
}

const scriptInfo = projectService.getScriptInfo(parseSettings.filePath);
const program = projectService
const scriptInfo = service.getScriptInfo(filePath);
const program = service
.getDefaultProjectForFile(scriptInfo!.fileName, true)!
.getLanguageService(/*ensureSynchronized*/ true)
.getProgram();
Expand All @@ -30,10 +48,13 @@ export function useProgramFromProjectService(
}

return createProjectProgram(parseSettings, [program]);
}

function absolutify(filePath: string): string {
return path.isAbsolute(filePath)
? filePath
: path.join(projectService.host.getCurrentDirectory(), filePath);
}
function filePathMatchedBy(
filePath: string,
allowDefaultProjectForFiles: string[] | undefined,
): boolean {
return !!allowDefaultProjectForFiles?.some(pattern =>
minimatch(filePath, pattern),
);
}

0 comments on commit 7ddadda

Please sign in to comment.