From a092809a9746bb05076e9dd0a8ffd610915dc97a Mon Sep 17 00:00:00 2001 From: "bjz@Brads-MacBook-Pro.local" <> Date: Tue, 6 Dec 2022 15:46:53 +1030 Subject: [PATCH] feat(typescript-estree): add experimental mode for type-aware linting that uses a language service instead of a builder program ## PR Checklist - [x] Steps in [CONTRIBUTING.md](https://github.com/typescript-eslint/typescript-eslint/blob/main/CONTRIBUTING.md) were taken ## Overview This is just an experiment to help address memory issues (#1192), maybe performance, and maybe out-of-sync types (#5845). I was looking into some code around the place and noticed that TS exposes the concept of a "document registry" - which is a shared cache that can be reused across certain TS data structures to deduplicate memory usage (and I assume improve performance by deduplicating work). This PR implements a new parser strategy which uses TS's "Language Service" tooling, which in turn leverages the document registry. "persistent parse" tests pass - which at least proves that it works in some manner of speaking. One interesting thing to note here is under the hood the language serivce doesn't use a builder program. I believe the idea is that the document registry is supposed to forego the performance implications of that? I don't know exactly - it will require more testing. Though it's worth mentioning that this means this could replace our current "single-run" codepaths because it doesn't use a builder program. TODO: - figure out how to roll-back "dirty" states - memory pressure testing - runtime performance testing --- .../create-program/createProjectProgram.ts | 7 +- .../getLanguageServiceProgram.ts | 336 +++++++++ .../getWatchProgramsForProjects.ts | 68 +- .../src/create-program/shared.ts | 96 ++- packages/typescript-estree/src/index.ts | 2 +- .../src/parseSettings/createParseSettings.ts | 13 +- .../src/parseSettings/index.ts | 16 +- .../typescript-estree/src/parser-options.ts | 7 + packages/typescript-estree/src/parser.ts | 49 +- .../tests/lib/persistentParse.test.ts | 641 +++++++++++++----- .../tests/lib/semanticInfo.test.ts | 2 +- .../website/src/components/linter/config.ts | 6 +- 12 files changed, 982 insertions(+), 261 deletions(-) create mode 100644 packages/typescript-estree/src/create-program/getLanguageServiceProgram.ts diff --git a/packages/typescript-estree/src/create-program/createProjectProgram.ts b/packages/typescript-estree/src/create-program/createProjectProgram.ts index 784b44b93d4d..5a5cb11fd425 100644 --- a/packages/typescript-estree/src/create-program/createProjectProgram.ts +++ b/packages/typescript-estree/src/create-program/createProjectProgram.ts @@ -4,8 +4,7 @@ import * as ts from 'typescript'; import { firstDefined } from '../node-utils'; import type { ParseSettings } from '../parseSettings'; -import { getWatchProgramsForProjects } from './getWatchProgramsForProjects'; -import type { ASTAndProgram } from './shared'; +import type { ASTAndProgram, CanonicalPath } from './shared'; import { getAstFromProgram } from './shared'; const log = debug('typescript-eslint:typescript-estree:createProjectProgram'); @@ -27,10 +26,10 @@ const DEFAULT_EXTRA_FILE_EXTENSIONS = [ */ function createProjectProgram( parseSettings: ParseSettings, + programsForProjects: readonly ts.Program[], ): ASTAndProgram | undefined { log('Creating project program for: %s', parseSettings.filePath); - const programsForProjects = getWatchProgramsForProjects(parseSettings); const astAndProgram = firstDefined(programsForProjects, currentProgram => getAstFromProgram(currentProgram, parseSettings), ); @@ -40,7 +39,7 @@ function createProjectProgram( return astAndProgram; } - const describeFilePath = (filePath: string): string => { + const describeFilePath = (filePath: CanonicalPath): string => { const relative = path.relative( parseSettings.tsconfigRootDir || process.cwd(), filePath, diff --git a/packages/typescript-estree/src/create-program/getLanguageServiceProgram.ts b/packages/typescript-estree/src/create-program/getLanguageServiceProgram.ts new file mode 100644 index 000000000000..a75b22867ffc --- /dev/null +++ b/packages/typescript-estree/src/create-program/getLanguageServiceProgram.ts @@ -0,0 +1,336 @@ +import debug from 'debug'; +import * as ts from 'typescript'; + +import type { ParseSettings } from '../parseSettings'; +import { getScriptKind } from './getScriptKind'; +import type { CanonicalPath, FileHash, TSConfigCanonicalPath } from './shared'; +import { + createDefaultCompilerOptionsFromExtra, + createHash, + getCanonicalFileName, + registerAdditionalCacheClearer, + useCaseSensitiveFileNames, +} from './shared'; + +const log = debug( + 'typescript-eslint:typescript-estree:getLanguageServiceProgram', +); + +type KnownLanguageService = Readonly<{ + configFile: ts.ParsedCommandLine; + fileList: ReadonlySet; + languageService: ts.LanguageService; +}>; +/** + * Maps tsconfig paths to their corresponding file contents and resulting watches + */ +const knownLanguageServiceMap = new Map< + TSConfigCanonicalPath, + KnownLanguageService +>(); + +type CachedFile = Readonly<{ + hash: FileHash; + snapshot: ts.IScriptSnapshot; + // starts at 0 and increments each time we see new text for the file + version: number; +}>; +/** + * Stores the hashes of files so we know if we need to inform TS of file changes. + */ +const parsedFileCache = new Map(); + +registerAdditionalCacheClearer(() => { + knownLanguageServiceMap.clear(); + parsedFileCache.clear(); + documentRegistry = null; +}); + +/** + * Holds information about the file currently being linted + */ +const currentLintOperationState: { code: string; filePath: CanonicalPath } = { + code: '', + filePath: '' as CanonicalPath, +}; + +/** + * Persistent text document registry that shares text documents across programs to + * reduce memory overhead. + * + * We don't initialize this until the first time we run the code. + */ +let documentRegistry: ts.DocumentRegistry | null; + +function maybeUpdateFile( + filePath: CanonicalPath, + fileContents: string | undefined, + parseSettings: ParseSettings, +): boolean { + if (fileContents == null || documentRegistry == null) { + return false; + } + + const newCodeHash = createHash(fileContents); + const cachedParsedFile = parsedFileCache.get(filePath); + if (cachedParsedFile?.hash === newCodeHash) { + // nothing needs updating + return false; + } + + const snapshot = ts.ScriptSnapshot.fromString(fileContents); + const version = (cachedParsedFile?.version ?? 0) + 1; + parsedFileCache.set(filePath, { + hash: newCodeHash, + snapshot, + version, + }); + + for (const { configFile } of knownLanguageServiceMap.values()) { + /* + TODO - this isn't safe or correct. + + When the user edits a file IDE integrations will run ESLint on the unsaved text. + This will cause us to update our registry with the new "dirty" text content. + + If the user saves the file, then dirty becomes clean and we're happy because + when the user edits the next file we've already updated our state. + + However if the user closes the file without saving, then the registry will be + stuck with the dirty text, which could cause issues that can only be fixed by + either (a) restarting the IDE or (b) opening the clean file again. + + This is the reason that the builder program version doesn't re-use the + current parsed text any longer than the duration of the current parse. + + Problem notes: + - we can't attach disk watchers because we don't know if we're in a CLI or an + IDE environment. This means we don't know when a change is committed for a + file. + - ESLint has there's no mechanism to tell us when the lint run is done, so + we don't know when it's safe to roll-back the update. + - maybe this doesn't matter and we can just roll-back the change after + we finish the current parse (i.e. return the dirty program?). + - we don't own the IDE integration so we don't know when a file closes in a + dirty state, nor do we know when a file is opened in a clean state. + + TODO for now. Will need to solve before we can consider releasing. + */ + documentRegistry.updateDocument( + filePath, + configFile.options, + snapshot, + version.toString(), + getScriptKind(filePath, parseSettings.jsx), + ); + } + + return true; +} + +export function getLanguageServiceProgram( + parseSettings: ParseSettings, +): ts.Program[] { + if (!documentRegistry) { + documentRegistry = ts.createDocumentRegistry( + useCaseSensitiveFileNames, + process.cwd(), + ); + } + + const filePath = getCanonicalFileName(parseSettings.filePath); + + // preserve reference to code and file being linted + currentLintOperationState.code = parseSettings.code; + currentLintOperationState.filePath = filePath; + + // Update file version if necessary + maybeUpdateFile(filePath, parseSettings.code, parseSettings); + + const currentProjectsFromSettings = new Set(parseSettings.projects); + + /* + * before we go into the process of attempting to find and update every program + * see if we know of a program that contains this file + */ + for (const [ + tsconfigPath, + { fileList, languageService }, + ] of knownLanguageServiceMap.entries()) { + if (!currentProjectsFromSettings.has(tsconfigPath)) { + // the current parser run doesn't specify this tsconfig in parserOptions.project + // so we don't want to consider it for caching purposes. + // + // if we did consider it we might return a program for a project + // that wasn't specified in the current parser run (which is obv bad!). + continue; + } + + if (fileList.has(filePath)) { + log('Found existing language service - %s', tsconfigPath); + + const updatedProgram = languageService.getProgram(); + if (!updatedProgram) { + log( + 'Could not get program from language service for project %s', + tsconfigPath, + ); + continue; + } + // TODO - do we need this? + // sets parent pointers in source files + // updatedProgram.getTypeChecker(); + + return [updatedProgram]; + } + } + log( + 'File did not belong to any existing language services, moving to create/update. %s', + filePath, + ); + + const results = []; + + /* + * We don't know of a program that contains the file, this means that either: + * - the required program hasn't been created yet, or + * - the file is new/renamed, and the program hasn't been updated. + */ + for (const tsconfigPath of parseSettings.projects) { + const existingLanguageService = knownLanguageServiceMap.get(tsconfigPath); + + if (existingLanguageService) { + const result = createLanguageService(tsconfigPath, parseSettings); + if (result == null) { + log('could not update language service %s', tsconfigPath); + continue; + } + const updatedProgram = result.program; + + // TODO - do we need this? + // sets parent pointers in source files + // updatedProgram.getTypeChecker(); + + // cache and check the file list + const fileList = existingLanguageService.fileList; + if (fileList.has(filePath)) { + log('Found updated program %s', tsconfigPath); + // we can return early because we know this program contains the file + return [updatedProgram]; + } + + results.push(updatedProgram); + continue; + } + + const result = createLanguageService(tsconfigPath, parseSettings); + if (result == null) { + continue; + } + + const { fileList, program } = result; + + // cache and check the file list + if (fileList.has(filePath)) { + log('Found program for file. %s', filePath); + // we can return early because we know this program contains the file + return [program]; + } + + results.push(program); + } + + return results; +} + +function createLanguageService( + tsconfigPath: TSConfigCanonicalPath, + parseSettings: ParseSettings, +): { fileList: ReadonlySet; program: ts.Program } | null { + const configFile = ts.getParsedCommandLineOfConfigFile( + tsconfigPath, + createDefaultCompilerOptionsFromExtra(parseSettings), + { + ...ts.sys, + onUnRecoverableConfigFileDiagnostic: diagnostic => { + throw new Error( + ts.flattenDiagnosticMessageText( + diagnostic.messageText, + ts.sys.newLine, + ), + ); + }, + }, + ); + if (configFile == null) { + // this should be unreachable because we throw on unrecoverable diagnostics + log('Unable to parse config file %s', tsconfigPath); + return null; + } + + const host: ts.LanguageServiceHost = { + ...ts.sys, + getCompilationSettings: () => configFile.options, + getScriptFileNames: () => configFile.fileNames, + getScriptVersion: filePathIn => { + const filePath = getCanonicalFileName(filePathIn); + return parsedFileCache.get(filePath)?.version.toString(10) ?? '0'; + }, + getScriptSnapshot: filePathIn => { + const filePath = getCanonicalFileName(filePathIn); + const cached = parsedFileCache.get(filePath); + if (cached) { + return cached.snapshot; + } + + const contents = host.readFile(filePathIn); + if (contents == null) { + return undefined; + } + + return ts.ScriptSnapshot.fromString(contents); + }, + getDefaultLibFileName: ts.getDefaultLibFileName, + readFile: (filePathIn, encoding) => { + const filePath = getCanonicalFileName(filePathIn); + const cached = parsedFileCache.get(filePath); + if (cached) { + return cached.snapshot.getText(0, cached.snapshot.getLength()); + } + + const fileContent = + filePath === currentLintOperationState.filePath + ? currentLintOperationState.code + : ts.sys.readFile(filePath, encoding); + maybeUpdateFile(filePath, fileContent, parseSettings); + return fileContent; + }, + useCaseSensitiveFileNames: () => useCaseSensitiveFileNames, + }; + + if (documentRegistry == null) { + // should be impossible to reach + throw new Error( + 'Unexpected state - document registry was not initialized.', + ); + } + + const languageService = ts.createLanguageService(host, documentRegistry); + const fileList = new Set(configFile.fileNames.map(getCanonicalFileName)); + knownLanguageServiceMap.set(tsconfigPath, { + configFile, + fileList, + languageService, + }); + + const program = languageService.getProgram(); + if (program == null) { + log( + 'Unable to get program from language service for config %s', + tsconfigPath, + ); + return null; + } + + return { fileList, program }; +} diff --git a/packages/typescript-estree/src/create-program/getWatchProgramsForProjects.ts b/packages/typescript-estree/src/create-program/getWatchProgramsForProjects.ts index 15d88e5f4540..ad5d85292114 100644 --- a/packages/typescript-estree/src/create-program/getWatchProgramsForProjects.ts +++ b/packages/typescript-estree/src/create-program/getWatchProgramsForProjects.ts @@ -4,22 +4,27 @@ import semver from 'semver'; import * as ts from 'typescript'; import type { ParseSettings } from '../parseSettings'; -import type { CanonicalPath } from './shared'; +import type { CanonicalPath, FileHash, TSConfigCanonicalPath } from './shared'; import { canonicalDirname, createDefaultCompilerOptionsFromExtra, + createHash, getCanonicalFileName, getModuleResolver, + hasTSConfigChanged, + registerAdditionalCacheClearer, } from './shared'; import type { WatchCompilerHostOfConfigFile } from './WatchCompilerHostOfConfigFile'; -const log = debug('typescript-eslint:typescript-estree:createWatchProgram'); +const log = debug( + 'typescript-eslint:typescript-estree:getWatchProgramsForProjects', +); /** * Maps tsconfig paths to their corresponding file contents and resulting watches */ const knownWatchProgramMap = new Map< - CanonicalPath, + TSConfigCanonicalPath, ts.WatchOfConfigFile >(); @@ -39,27 +44,23 @@ const folderWatchCallbackTrackingMap = new Map< /** * Stores the list of known files for each program */ -const programFileListCache = new Map>(); +const programFileListCache = new Map< + TSConfigCanonicalPath, + Set +>(); /** - * Caches the last modified time of the tsconfig files + * Stores the hashes of files so we know if we need to inform TS of file changes. */ -const tsconfigLastModifiedTimestampCache = new Map(); - -const parsedFilesSeenHash = new Map(); +const parsedFilesSeenHash = new Map(); -/** - * Clear all of the parser caches. - * This should only be used in testing to ensure the parser is clean between tests. - */ -function clearWatchCaches(): void { +registerAdditionalCacheClearer(() => { knownWatchProgramMap.clear(); fileWatchCallbackTrackingMap.clear(); folderWatchCallbackTrackingMap.clear(); parsedFilesSeenHash.clear(); programFileListCache.clear(); - tsconfigLastModifiedTimestampCache.clear(); -} +}); function saveWatchCallback( trackingMap: Map>, @@ -105,21 +106,8 @@ function diagnosticReporter(diagnostic: ts.Diagnostic): void { ); } -/** - * Hash content for compare content. - * @param content hashed contend - * @returns hashed result - */ -function createHash(content: string): string { - // No ts.sys in browser environments. - if (ts.sys?.createHash) { - return ts.sys.createHash(content); - } - return content; -} - function updateCachedFileList( - tsconfigPath: CanonicalPath, + tsconfigPath: TSConfigCanonicalPath, program: ts.Program, parseSettings: ParseSettings, ): Set { @@ -142,7 +130,6 @@ function getWatchProgramsForProjects( parseSettings: ParseSettings, ): ts.Program[] { const filePath = getCanonicalFileName(parseSettings.filePath); - const results = []; // preserve reference to code and file being linted currentLintOperationState.code = parseSettings.code; @@ -203,6 +190,8 @@ function getWatchProgramsForProjects( filePath, ); + const results = []; + /* * We don't know of a program that contains the file, this means that either: * - the required program hasn't been created yet, or @@ -405,25 +394,10 @@ function createWatchProgram( return watch; } -function hasTSConfigChanged(tsconfigPath: CanonicalPath): boolean { - const stat = fs.statSync(tsconfigPath); - const lastModifiedAt = stat.mtimeMs; - const cachedLastModifiedAt = - tsconfigLastModifiedTimestampCache.get(tsconfigPath); - - tsconfigLastModifiedTimestampCache.set(tsconfigPath, lastModifiedAt); - - if (cachedLastModifiedAt === undefined) { - return false; - } - - return Math.abs(cachedLastModifiedAt - lastModifiedAt) > Number.EPSILON; -} - function maybeInvalidateProgram( existingWatch: ts.WatchOfConfigFile, filePath: CanonicalPath, - tsconfigPath: CanonicalPath, + tsconfigPath: TSConfigCanonicalPath, ): ts.Program | null { /* * By calling watchProgram.getProgram(), it will trigger a resync of the program based on @@ -550,4 +524,4 @@ function maybeInvalidateProgram( return null; } -export { clearWatchCaches, getWatchProgramsForProjects }; +export { getWatchProgramsForProjects }; diff --git a/packages/typescript-estree/src/create-program/shared.ts b/packages/typescript-estree/src/create-program/shared.ts index dd50f757dce1..9285329834cf 100644 --- a/packages/typescript-estree/src/create-program/shared.ts +++ b/packages/typescript-estree/src/create-program/shared.ts @@ -1,3 +1,4 @@ +import fs from 'fs'; import path from 'path'; import type { Program } from 'typescript'; import * as ts from 'typescript'; @@ -33,7 +34,7 @@ const DEFAULT_COMPILER_OPTIONS: ts.CompilerOptions = { checkJs: true, }; -function createDefaultCompilerOptionsFromExtra( +function createDefaultCompilerOptionsFromParseSettings( parseSettings: ParseSettings, ): ts.CompilerOptions { if (parseSettings.debugLevel.has('typescript')) { @@ -47,7 +48,10 @@ function createDefaultCompilerOptionsFromExtra( } // This narrows the type so we can be sure we're passing canonical names in the correct places -type CanonicalPath = string & { __brand: unknown }; +type CanonicalPath = string & { __canonicalPathBrand: unknown }; +type TSConfigCanonicalPath = CanonicalPath & { + __tsconfigCanonicalPathBrand: unknown; +}; // typescript doesn't provide a ts.sys implementation for browser environments const useCaseSensitiveFileNames = @@ -64,10 +68,14 @@ function getCanonicalFileName(filePath: string): CanonicalPath { return correctPathCasing(normalized) as CanonicalPath; } -function ensureAbsolutePath(p: string, tsconfigRootDir: string): string { - return path.isAbsolute(p) - ? p - : path.join(tsconfigRootDir || process.cwd(), p); +function getTsconfigCanonicalFileName(filePath: string): TSConfigCanonicalPath { + return getCanonicalFileName(filePath) as TSConfigCanonicalPath; +} + +function ensureAbsolutePath(p: string, tsconfigRootDir: string): CanonicalPath { + return getCanonicalFileName( + path.isAbsolute(p) ? p : path.join(tsconfigRootDir || process.cwd(), p), + ); } function canonicalDirname(p: CanonicalPath): CanonicalPath { @@ -124,14 +132,88 @@ function getModuleResolver(moduleResolverPath: string): ModuleResolver { return moduleResolver; } +/** + * Same fallback hashing algorithm TS uses: + * https://github.com/microsoft/TypeScript/blob/00dc0b6674eef3fbb3abb86f9d71705b11134446/src/compiler/sys.ts#L54-L66 + */ +function generateDjb2Hash(data: string): string { + let acc = 5381; + for (let i = 0; i < data.length; i++) { + acc = (acc << 5) + acc + data.charCodeAt(i); + } + return acc.toString(); +} + +type FileHash = string & { __fileHashBrand: unknown }; +/** + * Hash content for compare content. + * @param content hashed contend + * @returns hashed result + */ +function createHash(content: string): FileHash { + // No ts.sys in browser environments. + if (ts.sys?.createHash) { + return ts.sys.createHash(content) as FileHash; + } + return generateDjb2Hash(content) as FileHash; +} + +/** + * Caches the last modified time of the tsconfig files + */ +const tsconfigLastModifiedTimestampCache = new Map< + TSConfigCanonicalPath, + number +>(); + +function hasTSConfigChanged(tsconfigPath: TSConfigCanonicalPath): boolean { + const stat = fs.statSync(tsconfigPath); + const lastModifiedAt = stat.mtimeMs; + const cachedLastModifiedAt = + tsconfigLastModifiedTimestampCache.get(tsconfigPath); + + tsconfigLastModifiedTimestampCache.set(tsconfigPath, lastModifiedAt); + + if (cachedLastModifiedAt === undefined) { + return false; + } + + return Math.abs(cachedLastModifiedAt - lastModifiedAt) > Number.EPSILON; +} + +type CacheClearer = () => void; +const additionalCacheClearers: CacheClearer[] = []; + +/** + * Clear all of the parser caches. + * This should only be used in testing to ensure the parser is clean between tests. + */ +function clearWatchCaches(): void { + tsconfigLastModifiedTimestampCache.clear(); + for (const fn of additionalCacheClearers) { + fn(); + } +} +function registerAdditionalCacheClearer(fn: CacheClearer): void { + additionalCacheClearers.push(fn); +} + export { ASTAndProgram, CORE_COMPILER_OPTIONS, canonicalDirname, CanonicalPath, - createDefaultCompilerOptionsFromExtra, + clearWatchCaches, + createDefaultCompilerOptionsFromParseSettings as createDefaultCompilerOptionsFromExtra, + createHash, ensureAbsolutePath, + FileHash, getCanonicalFileName, getAstFromProgram, getModuleResolver, + getTsconfigCanonicalFileName, + hasTSConfigChanged, + registerAdditionalCacheClearer, + TSConfigCanonicalPath, + useCaseSensitiveFileNames, }; diff --git a/packages/typescript-estree/src/index.ts b/packages/typescript-estree/src/index.ts index bc7ed6024f3b..efb10b382228 100644 --- a/packages/typescript-estree/src/index.ts +++ b/packages/typescript-estree/src/index.ts @@ -10,7 +10,7 @@ export { export { ParserServices, TSESTreeOptions } from './parser-options'; export { simpleTraverse } from './simple-traverse'; export * from './ts-estree'; -export { clearWatchCaches as clearCaches } from './create-program/getWatchProgramsForProjects'; +export { clearWatchCaches as clearCaches } from './create-program/shared'; export { createProgramFromConfigFile as createProgram } from './create-program/useProvidedPrograms'; export * from './create-program/getScriptKind'; export { typescriptVersionIsAtLeast } from './version-check'; diff --git a/packages/typescript-estree/src/parseSettings/createParseSettings.ts b/packages/typescript-estree/src/parseSettings/createParseSettings.ts index b1cde9d4c9ad..f542ba0a5a5a 100644 --- a/packages/typescript-estree/src/parseSettings/createParseSettings.ts +++ b/packages/typescript-estree/src/parseSettings/createParseSettings.ts @@ -2,10 +2,11 @@ import debug from 'debug'; import { sync as globSync } from 'globby'; import isGlob from 'is-glob'; -import type { CanonicalPath } from '../create-program/shared'; +import type { TSConfigCanonicalPath } from '../create-program/shared'; import { ensureAbsolutePath, getCanonicalFileName, + getTsconfigCanonicalFileName, } from '../create-program/shared'; import type { TSESTreeOptions } from '../parser-options'; import type { MutableParseSettings } from './index'; @@ -39,6 +40,8 @@ export function createParseSettings( errorOnUnknownASTType: options.errorOnUnknownASTType === true, EXPERIMENTAL_useSourceOfProjectReferenceRedirect: options.EXPERIMENTAL_useSourceOfProjectReferenceRedirect === true, + EXPERIMENTAL_useLanguageService: + options.EXPERIMENTAL_useLanguageService === true, extraFileExtensions: Array.isArray(options.extraFileExtensions) && options.extraFileExtensions.every(ext => typeof ext === 'string') @@ -65,7 +68,7 @@ export function createParseSettings( range: options.range === true, singleRun: inferSingleRun(options), tokens: options.tokens === true ? [] : null, - tsconfigRootDir, + tsconfigRootDir: getCanonicalFileName(tsconfigRootDir), }; // debug doesn't support multiple `enable` calls, so have to do it all at once @@ -148,8 +151,8 @@ function getFileName(jsx?: boolean): string { function getTsconfigPath( tsconfigPath: string, tsconfigRootDir: string, -): CanonicalPath { - return getCanonicalFileName( +): TSConfigCanonicalPath { + return getTsconfigCanonicalFileName( ensureAbsolutePath(tsconfigPath, tsconfigRootDir), ); } @@ -161,7 +164,7 @@ function prepareAndTransformProjects( tsconfigRootDir: string, projectsInput: string | string[] | undefined, ignoreListInput: string[], -): CanonicalPath[] { +): TSConfigCanonicalPath[] { const sanitizedProjects: string[] = []; // Normalize and sanitize the project paths diff --git a/packages/typescript-estree/src/parseSettings/index.ts b/packages/typescript-estree/src/parseSettings/index.ts index 0a9734d1b241..636b9de25292 100644 --- a/packages/typescript-estree/src/parseSettings/index.ts +++ b/packages/typescript-estree/src/parseSettings/index.ts @@ -1,6 +1,9 @@ import type * as ts from 'typescript'; -import type { CanonicalPath } from '../create-program/shared'; +import type { + CanonicalPath, + TSConfigCanonicalPath, +} from '../create-program/shared'; import type { TSESTree } from '../ts-estree'; type DebugModule = 'typescript-eslint' | 'eslint' | 'typescript'; @@ -53,6 +56,11 @@ export interface MutableParseSettings { */ EXPERIMENTAL_useSourceOfProjectReferenceRedirect: boolean; + /** + * Whether to use a LanguageService for program management or not. + */ + EXPERIMENTAL_useLanguageService: boolean; + /** * Any non-standard file extensions which will be parsed. */ @@ -61,7 +69,7 @@ export interface MutableParseSettings { /** * Path of the file being parsed. */ - filePath: string; + filePath: CanonicalPath; /** * Whether parsing of JSX is enabled. @@ -98,7 +106,7 @@ export interface MutableParseSettings { /** * Normalized paths to provided project paths. */ - projects: CanonicalPath[]; + projects: TSConfigCanonicalPath[]; /** * Whether to add the `range` property to AST nodes. @@ -118,7 +126,7 @@ export interface MutableParseSettings { /** * The absolute path to the root directory for all provided `project`s. */ - tsconfigRootDir: string; + tsconfigRootDir: CanonicalPath; } export type ParseSettings = Readonly; diff --git a/packages/typescript-estree/src/parser-options.ts b/packages/typescript-estree/src/parser-options.ts index 632d9e6ae883..b5507728c090 100644 --- a/packages/typescript-estree/src/parser-options.ts +++ b/packages/typescript-estree/src/parser-options.ts @@ -92,6 +92,13 @@ interface ParseAndGenerateServicesOptions extends ParseOptions { */ EXPERIMENTAL_useSourceOfProjectReferenceRedirect?: boolean; + /** + * ***EXPERIMENTAL FLAG*** - Use this at your own risk. + * + * Manage type-aware parsing using a `ts.LanguageService` instead of a `ts.BuilderProgram`. + */ + EXPERIMENTAL_useLanguageService?: boolean; + /** * When `project` is provided, this controls the non-standard file extensions which will be parsed. * It accepts an array of file extensions, each preceded by a `.`. diff --git a/packages/typescript-estree/src/parser.ts b/packages/typescript-estree/src/parser.ts index c5504ba961a0..9086949b6dfd 100644 --- a/packages/typescript-estree/src/parser.ts +++ b/packages/typescript-estree/src/parser.ts @@ -7,6 +7,8 @@ import { createDefaultProgram } from './create-program/createDefaultProgram'; import { createIsolatedProgram } from './create-program/createIsolatedProgram'; import { createProjectProgram } from './create-program/createProjectProgram'; import { createSourceFile } from './create-program/createSourceFile'; +import { getLanguageServiceProgram } from './create-program/getLanguageServiceProgram'; +import { getWatchProgramsForProjects } from './create-program/getWatchProgramsForProjects'; import type { ASTAndProgram, CanonicalPath } from './create-program/shared'; import { createProgramFromConfigFile, @@ -39,15 +41,44 @@ function getProgramAndAST( parseSettings: ParseSettings, shouldProvideParserServices: boolean, ): ASTAndProgram { - return ( - (parseSettings.programs && - useProvidedPrograms(parseSettings.programs, parseSettings)) || - (shouldProvideParserServices && createProjectProgram(parseSettings)) || - (shouldProvideParserServices && - parseSettings.createDefaultProgram && - createDefaultProgram(parseSettings)) || - createIsolatedProgram(parseSettings) - ); + if (parseSettings.programs) { + const providedPrograms = useProvidedPrograms( + parseSettings.programs, + parseSettings, + ); + if (providedPrograms) { + return providedPrograms; + } + } + + if (shouldProvideParserServices) { + if (parseSettings.EXPERIMENTAL_useLanguageService) { + const languageServiceProgram = createProjectProgram( + parseSettings, + getLanguageServiceProgram(parseSettings), + ); + if (languageServiceProgram) { + return languageServiceProgram; + } + } else { + const watchProgram = createProjectProgram( + parseSettings, + getWatchProgramsForProjects(parseSettings), + ); + if (watchProgram) { + return watchProgram; + } + } + + if (parseSettings.createDefaultProgram) { + const defaultProgram = createDefaultProgram(parseSettings); + if (defaultProgram) { + return defaultProgram; + } + } + } + + return createIsolatedProgram(parseSettings); } // eslint-disable-next-line @typescript-eslint/no-empty-interface diff --git a/packages/typescript-estree/tests/lib/persistentParse.test.ts b/packages/typescript-estree/tests/lib/persistentParse.test.ts index 63e81d7e260a..52d197a38220 100644 --- a/packages/typescript-estree/tests/lib/persistentParse.test.ts +++ b/packages/typescript-estree/tests/lib/persistentParse.test.ts @@ -2,17 +2,88 @@ import fs from 'fs'; import path from 'path'; import tmp from 'tmp'; -import { clearWatchCaches } from '../../src/create-program/getWatchProgramsForProjects'; +import type { TSESTreeOptions } from '../../src'; +import { AST_NODE_TYPES, simpleTraverse } from '../../src'; +import { clearWatchCaches } from '../../src/create-program/shared'; +import type { ParseAndGenerateServicesResult } from '../../src/parser'; import { parseAndGenerateServices } from '../../src/parser'; const CONTENTS = { - foo: 'console.log("foo")', - bar: 'console.log("bar")', - 'baz/bar': 'console.log("baz bar")', - 'bat/baz/bar': 'console.log("bat/baz/bar")', - number: 'const foo = 1;', - object: '(() => { })();', - string: 'let a: "a" | "b";', + foo: { + code: 'const x = "foo";', + types: [ + [AST_NODE_TYPES.Program, 'any'], + [AST_NODE_TYPES.VariableDeclaration, 'any'], + [AST_NODE_TYPES.VariableDeclarator, '"foo"'], + [AST_NODE_TYPES.Identifier, '"foo"'], + [AST_NODE_TYPES.Literal, '"foo"'], + ], + }, + bar: { + code: 'const x = "bar";', + types: [ + [AST_NODE_TYPES.Program, 'any'], + [AST_NODE_TYPES.VariableDeclaration, 'any'], + [AST_NODE_TYPES.VariableDeclarator, '"bar"'], + [AST_NODE_TYPES.Identifier, '"bar"'], + [AST_NODE_TYPES.Literal, '"bar"'], + ], + }, + 'baz/bar': { + code: 'const x = "baz bar";', + types: [ + [AST_NODE_TYPES.Program, 'any'], + [AST_NODE_TYPES.VariableDeclaration, 'any'], + [AST_NODE_TYPES.VariableDeclarator, '"baz bar"'], + [AST_NODE_TYPES.Identifier, '"baz bar"'], + [AST_NODE_TYPES.Literal, '"baz bar"'], + ], + }, + 'bat/baz/bar': { + code: 'const x = "bat/baz/bar";', + types: [ + [AST_NODE_TYPES.Program, 'any'], + [AST_NODE_TYPES.VariableDeclaration, 'any'], + [AST_NODE_TYPES.VariableDeclarator, '"bat/baz/bar"'], + [AST_NODE_TYPES.Identifier, '"bat/baz/bar"'], + [AST_NODE_TYPES.Literal, '"bat/baz/bar"'], + ], + }, + number: { + code: 'const foo = 1;', + types: [ + [AST_NODE_TYPES.Program, 'any'], + [AST_NODE_TYPES.VariableDeclaration, 'any'], + [AST_NODE_TYPES.VariableDeclarator, '1'], + [AST_NODE_TYPES.Identifier, '1'], + [AST_NODE_TYPES.Literal, '1'], + ], + }, + object: { + code: '(() => { })();', + types: [ + [AST_NODE_TYPES.Program, 'any'], + [AST_NODE_TYPES.ExpressionStatement, 'any'], + [AST_NODE_TYPES.CallExpression, 'void'], + [AST_NODE_TYPES.ArrowFunctionExpression, '() => void'], + [AST_NODE_TYPES.BlockStatement, 'any'], + ], + }, + string: { + code: 'let a: "a" | "b";', + types: [ + [AST_NODE_TYPES.Program, 'any'], + [AST_NODE_TYPES.VariableDeclaration, 'any'], + [AST_NODE_TYPES.VariableDeclarator, '"a" | "b"'], + [AST_NODE_TYPES.Identifier, '"a" | "b"'], + [AST_NODE_TYPES.TSTypeAnnotation, 'any'], + [AST_NODE_TYPES.TSUnionType, '"a" | "b"'], + [AST_NODE_TYPES.TSLiteralType, '"a"'], + [AST_NODE_TYPES.Literal, 'any'], + [AST_NODE_TYPES.TSLiteralType, '"b"'], + [AST_NODE_TYPES.Literal, 'any'], + ], + }, }; const cwdCopy = process.cwd(); @@ -20,7 +91,8 @@ const tmpDirs = new Set(); afterEach(() => { // stop watching the files and folders clearWatchCaches(); - +}); +afterAll(() => { // clean up the temporary files and folders tmpDirs.forEach(t => t.removeCallback()); tmpDirs.clear(); @@ -29,201 +101,370 @@ afterEach(() => { process.chdir(cwdCopy); }); -function writeTSConfig(dirName: string, config: Record): void { - fs.writeFileSync(path.join(dirName, 'tsconfig.json'), JSON.stringify(config)); -} -function writeFile(dirName: string, file: keyof typeof CONTENTS): void { - fs.writeFileSync(path.join(dirName, 'src', `${file}.ts`), CONTENTS[file]); -} -function renameFile(dirName: string, src: 'bar', dest: 'baz/bar'): void { - fs.renameSync( - path.join(dirName, 'src', `${src}.ts`), - path.join(dirName, 'src', `${dest}.ts`), - ); -} - -function createTmpDir(): tmp.DirResult { - const tmpDir = tmp.dirSync({ - keep: false, - unsafeCleanup: true, - }); - tmpDirs.add(tmpDir); - return tmpDir; -} -function setup(tsconfig: Record, writeBar = true): string { - const tmpDir = createTmpDir(); - - writeTSConfig(tmpDir.name, tsconfig); - - fs.mkdirSync(path.join(tmpDir.name, 'src')); - fs.mkdirSync(path.join(tmpDir.name, 'src', 'baz')); - writeFile(tmpDir.name, 'foo'); - writeBar && writeFile(tmpDir.name, 'bar'); - - return tmpDir.name; -} - -function parseFile( - filename: keyof typeof CONTENTS, - tmpDir: string, - relative?: boolean, - ignoreTsconfigRootDir?: boolean, -): void { - parseAndGenerateServices(CONTENTS[filename], { - project: './tsconfig.json', - tsconfigRootDir: ignoreTsconfigRootDir ? undefined : tmpDir, - filePath: relative - ? path.join('src', `${filename}.ts`) - : path.join(tmpDir, 'src', `${filename}.ts`), - }); -} - -function existsSync(filename: keyof typeof CONTENTS, tmpDir = ''): boolean { - return fs.existsSync(path.join(tmpDir, 'src', `${filename}.ts`)); -} - -function baseTests( - tsConfigExcludeBar: Record, - tsConfigIncludeAll: Record, -): void { - it('parses both files successfully when included', () => { - const PROJECT_DIR = setup(tsConfigIncludeAll); - - expect(() => parseFile('foo', PROJECT_DIR)).not.toThrow(); - expect(() => parseFile('bar', PROJECT_DIR)).not.toThrow(); - }); +function parserTests(extraOptions: TSESTreeOptions): void { + function writeTSConfig( + dirName: string, + config: Record, + ): void { + fs.writeFileSync( + path.join(dirName, 'tsconfig.json'), + JSON.stringify(config), + ); + } + function writeFile(dirName: string, file: keyof typeof CONTENTS): void { + fs.writeFileSync( + path.join(dirName, 'src', `${file}.ts`), + CONTENTS[file].code, + ); + } + function renameFile(dirName: string, src: 'bar', dest: 'baz/bar'): void { + fs.renameSync( + path.join(dirName, 'src', `${src}.ts`), + path.join(dirName, 'src', `${dest}.ts`), + ); + } + + function createTmpDir(): tmp.DirResult { + const tmpDir = tmp.dirSync({ + keep: false, + unsafeCleanup: true, + }); + tmpDirs.add(tmpDir); + return tmpDir; + } + function setup(tsconfig: Record, writeBar = true): string { + const tmpDir = createTmpDir(); + + writeTSConfig(tmpDir.name, tsconfig); + + fs.mkdirSync(path.join(tmpDir.name, 'src')); + fs.mkdirSync(path.join(tmpDir.name, 'src', 'baz')); + writeFile(tmpDir.name, 'foo'); + writeBar && writeFile(tmpDir.name, 'bar'); + + return tmpDir.name; + } + + function parseFile({ + filename, + ignoreTsconfigRootDir, + relative, + shouldThrowError, + tmpDir, + }: { + filename: keyof typeof CONTENTS; + ignoreTsconfigRootDir?: boolean; + relative?: boolean; + shouldThrowError?: boolean; + tmpDir: string; + }): void { + describe(filename, () => { + // eslint-disable-next-line @typescript-eslint/ban-types + const result = ((): ParseAndGenerateServicesResult<{}> | Error => { + try { + return parseAndGenerateServices(CONTENTS[filename].code, { + ...extraOptions, + project: './tsconfig.json', + tsconfigRootDir: ignoreTsconfigRootDir ? undefined : tmpDir, + filePath: relative + ? path.join('src', `${filename}.ts`) + : path.join(tmpDir, 'src', `${filename}.ts`), + }); + } catch (ex) { + return ex as Error; + } + })(); + if (shouldThrowError === true) { + it('should throw', () => { + expect(result).toBeInstanceOf(Error); + const message = (result as Error).message; + expect(message).toMatch( + new RegExp(`/src/${filename}`), + ); + expect(message).toMatch(/TSConfig does not include this file/); + }); + return; + } else { + it('should not throw', () => { + expect(result).not.toBeInstanceOf(Error); + }); + } + if (result instanceof Error) { + // not possible to reach + return; + } + + it('should have full type information available for nodes', () => { + expect(result.services.hasFullTypeInformation).toBeTruthy(); + const checker = result.services.program.getTypeChecker(); + const types: Array<[AST_NODE_TYPES, string]> = []; + simpleTraverse(result.ast, { + enter(node) { + const type = checker.getTypeAtLocation( + result.services.esTreeNodeToTSNodeMap.get(node), + ); + types.push([node.type, checker.typeToString(type)]); + }, + }); + expect(types).toStrictEqual(CONTENTS[filename].types); + }); + }); + } + + function existsSync(filename: keyof typeof CONTENTS, tmpDir = ''): boolean { + return fs.existsSync(path.join(tmpDir, 'src', `${filename}.ts`)); + } + + function baseTests( + tsConfigExcludeBar: Record, + tsConfigIncludeAll: Record, + ): void { + describe('parses both files successfully when included', () => { + const PROJECT_DIR = setup(tsConfigIncludeAll); + + parseFile({ + filename: 'foo', + tmpDir: PROJECT_DIR, + }); + parseFile({ + filename: 'bar', + tmpDir: PROJECT_DIR, + }); + }); - it('parses included files, and throws on excluded files', () => { - const PROJECT_DIR = setup(tsConfigExcludeBar); + describe('parses included files, and throws on excluded files', () => { + const PROJECT_DIR = setup(tsConfigExcludeBar); - expect(() => parseFile('foo', PROJECT_DIR)).not.toThrow(); - expect(() => parseFile('bar', PROJECT_DIR)).toThrow(); - }); + parseFile({ + filename: 'foo', + tmpDir: PROJECT_DIR, + }); + parseFile({ + shouldThrowError: true, + filename: 'bar', + tmpDir: PROJECT_DIR, + }); + }); - it('allows parsing of new files', () => { - const PROJECT_DIR = setup(tsConfigIncludeAll, false); + describe('allows parsing of new files', () => { + const PROJECT_DIR = setup(tsConfigIncludeAll, false); - // parse once to: assert the config as correct, and to make sure the program is setup - expect(() => parseFile('foo', PROJECT_DIR)).not.toThrow(); - // bar should throw because it doesn't exist yet - expect(() => parseFile('bar', PROJECT_DIR)).toThrow(); + // parse once to: assert the config as correct, and to make sure the program is setup + parseFile({ + filename: 'foo', + tmpDir: PROJECT_DIR, + }); + // bar should throw because it doesn't exist yet + parseFile({ + shouldThrowError: true, + filename: 'bar', + tmpDir: PROJECT_DIR, + }); - // write a new file and attempt to parse it - writeFile(PROJECT_DIR, 'bar'); + // write a new file and attempt to parse it + writeFile(PROJECT_DIR, 'bar'); - // both files should parse fine now - expect(() => parseFile('foo', PROJECT_DIR)).not.toThrow(); - expect(() => parseFile('bar', PROJECT_DIR)).not.toThrow(); - }); + // both files should parse fine now + parseFile({ + filename: 'foo', + tmpDir: PROJECT_DIR, + }); + parseFile({ + filename: 'bar', + tmpDir: PROJECT_DIR, + }); + }); - it('allows parsing of deeply nested new files', () => { - const PROJECT_DIR = setup(tsConfigIncludeAll, false); - const bazSlashBar = 'baz/bar' as const; + describe('allows parsing of deeply nested new files', () => { + const PROJECT_DIR = setup(tsConfigIncludeAll, false); + const bazSlashBar = 'baz/bar' as const; - // parse once to: assert the config as correct, and to make sure the program is setup - expect(() => parseFile('foo', PROJECT_DIR)).not.toThrow(); - // bar should throw because it doesn't exist yet - expect(() => parseFile(bazSlashBar, PROJECT_DIR)).toThrow(); + // parse once to: assert the config as correct, and to make sure the program is setup + parseFile({ + filename: 'foo', + tmpDir: PROJECT_DIR, + }); + // bar should throw because it doesn't exist yet + parseFile({ + shouldThrowError: true, + filename: bazSlashBar, + tmpDir: PROJECT_DIR, + }); - // write a new file and attempt to parse it - writeFile(PROJECT_DIR, bazSlashBar); + // write a new file and attempt to parse it + writeFile(PROJECT_DIR, bazSlashBar); - // both files should parse fine now - expect(() => parseFile('foo', PROJECT_DIR)).not.toThrow(); - expect(() => parseFile(bazSlashBar, PROJECT_DIR)).not.toThrow(); - }); + // both files should parse fine now + parseFile({ + filename: 'foo', + tmpDir: PROJECT_DIR, + }); + parseFile({ + filename: bazSlashBar, + tmpDir: PROJECT_DIR, + }); + }); - it('allows parsing of deeply nested new files in new folder', () => { - const PROJECT_DIR = setup(tsConfigIncludeAll); + describe('allows parsing of deeply nested new files in new folder', () => { + const PROJECT_DIR = setup(tsConfigIncludeAll); - expect(() => parseFile('foo', PROJECT_DIR)).not.toThrow(); + parseFile({ + filename: 'foo', + tmpDir: PROJECT_DIR, + }); - // Create deep folder structure after first parse (this is important step) - // context: https://github.com/typescript-eslint/typescript-eslint/issues/1394 - fs.mkdirSync(path.join(PROJECT_DIR, 'src', 'bat')); - fs.mkdirSync(path.join(PROJECT_DIR, 'src', 'bat', 'baz')); + // Create deep folder structure after first parse (this is important step) + // context: https://github.com/typescript-eslint/typescript-eslint/issues/1394 + fs.mkdirSync(path.join(PROJECT_DIR, 'src', 'bat')); + fs.mkdirSync(path.join(PROJECT_DIR, 'src', 'bat', 'baz')); - const bazSlashBar = 'bat/baz/bar' as const; + const bazSlashBar = 'bat/baz/bar' as const; - // write a new file and attempt to parse it - writeFile(PROJECT_DIR, bazSlashBar); + // write a new file and attempt to parse it + writeFile(PROJECT_DIR, bazSlashBar); - expect(() => parseFile(bazSlashBar, PROJECT_DIR)).not.toThrow(); - }); + parseFile({ + filename: bazSlashBar, + tmpDir: PROJECT_DIR, + }); + }); - it('allows renaming of files', () => { - const PROJECT_DIR = setup(tsConfigIncludeAll, true); - const bazSlashBar = 'baz/bar' as const; + describe('allows renaming of files', () => { + const PROJECT_DIR = setup(tsConfigIncludeAll, true); + const bazSlashBar = 'baz/bar' as const; - // parse once to: assert the config as correct, and to make sure the program is setup - expect(() => parseFile('foo', PROJECT_DIR)).not.toThrow(); - // bar should throw because it doesn't exist yet - expect(() => parseFile(bazSlashBar, PROJECT_DIR)).toThrow(); + // parse once to: assert the config as correct, and to make sure the program is setup + parseFile({ + filename: 'foo', + tmpDir: PROJECT_DIR, + }); + // bar should throw because it doesn't exist yet + parseFile({ + shouldThrowError: true, + filename: bazSlashBar, + tmpDir: PROJECT_DIR, + }); - // write a new file and attempt to parse it - renameFile(PROJECT_DIR, 'bar', bazSlashBar); + // write a new file and attempt to parse it + renameFile(PROJECT_DIR, 'bar', bazSlashBar); - // both files should parse fine now - expect(() => parseFile('foo', PROJECT_DIR)).not.toThrow(); - expect(() => parseFile(bazSlashBar, PROJECT_DIR)).not.toThrow(); - }); + // both files should parse fine now + parseFile({ + filename: 'foo', + tmpDir: PROJECT_DIR, + }); + parseFile({ + filename: bazSlashBar, + tmpDir: PROJECT_DIR, + }); + }); - it('reacts to changes in the tsconfig', () => { - const PROJECT_DIR = setup(tsConfigExcludeBar); + describe('reacts to changes in the tsconfig', () => { + const PROJECT_DIR = setup(tsConfigExcludeBar); - // parse once to: assert the config as correct, and to make sure the program is setup - expect(() => parseFile('foo', PROJECT_DIR)).not.toThrow(); - expect(() => parseFile('bar', PROJECT_DIR)).toThrow(); + // parse once to: assert the config as correct, and to make sure the program is setup + parseFile({ + filename: 'foo', + tmpDir: PROJECT_DIR, + }); + parseFile({ + shouldThrowError: true, + filename: 'bar', + tmpDir: PROJECT_DIR, + }); - // change the config file so it now includes all files - writeTSConfig(PROJECT_DIR, tsConfigIncludeAll); + // change the config file so it now includes all files + writeTSConfig(PROJECT_DIR, tsConfigIncludeAll); - expect(() => parseFile('foo', PROJECT_DIR)).not.toThrow(); - expect(() => parseFile('bar', PROJECT_DIR)).not.toThrow(); - }); + parseFile({ + filename: 'foo', + tmpDir: PROJECT_DIR, + }); + parseFile({ + filename: 'bar', + tmpDir: PROJECT_DIR, + }); + }); - it('should work with relative paths', () => { - const PROJECT_DIR = setup(tsConfigIncludeAll, false); + describe('should work with relative paths', () => { + const PROJECT_DIR = setup(tsConfigIncludeAll, false); - // parse once to: assert the config as correct, and to make sure the program is setup - expect(() => parseFile('foo', PROJECT_DIR, true)).not.toThrow(); - // bar should throw because it doesn't exist yet - expect(() => parseFile('bar', PROJECT_DIR, true)).toThrow(); + // parse once to: assert the config as correct, and to make sure the program is setup + parseFile({ + filename: 'foo', + tmpDir: PROJECT_DIR, + relative: true, + }); + // bar should throw because it doesn't exist yet + parseFile({ + shouldThrowError: true, + filename: 'bar', + tmpDir: PROJECT_DIR, + relative: true, + }); - // write a new file and attempt to parse it - writeFile(PROJECT_DIR, 'bar'); + // write a new file and attempt to parse it + writeFile(PROJECT_DIR, 'bar'); - // make sure that file is correctly created - expect(existsSync('bar', PROJECT_DIR)).toBe(true); + // make sure that file is correctly created + expect(existsSync('bar', PROJECT_DIR)).toBe(true); - // both files should parse fine now - expect(() => parseFile('foo', PROJECT_DIR, true)).not.toThrow(); - expect(() => parseFile('bar', PROJECT_DIR, true)).not.toThrow(); - }); + // both files should parse fine now + parseFile({ + filename: 'foo', + tmpDir: PROJECT_DIR, + relative: true, + }); + parseFile({ + filename: 'bar', + tmpDir: PROJECT_DIR, + relative: true, + }); + }); - it('should work with relative paths without tsconfig root', () => { - const PROJECT_DIR = setup(tsConfigIncludeAll, false); - process.chdir(PROJECT_DIR); + describe('should work with relative paths without tsconfig root', () => { + const PROJECT_DIR = setup(tsConfigIncludeAll, false); + process.chdir(PROJECT_DIR); - // parse once to: assert the config as correct, and to make sure the program is setup - expect(() => parseFile('foo', PROJECT_DIR, true, true)).not.toThrow(); - // bar should throw because it doesn't exist yet - expect(() => parseFile('bar', PROJECT_DIR, true, true)).toThrow(); + // parse once to: assert the config as correct, and to make sure the program is setup + parseFile({ + filename: 'foo', + tmpDir: PROJECT_DIR, + relative: true, + ignoreTsconfigRootDir: true, + }); + // bar should throw because it doesn't exist yet + parseFile({ + shouldThrowError: true, + filename: 'bar', + tmpDir: PROJECT_DIR, + relative: true, + ignoreTsconfigRootDir: true, + }); - // write a new file and attempt to parse it - writeFile(PROJECT_DIR, 'bar'); + // write a new file and attempt to parse it + writeFile(PROJECT_DIR, 'bar'); - // make sure that file is correctly created - expect(existsSync('bar')).toBe(true); - expect(existsSync('bar', PROJECT_DIR)).toBe(true); + // make sure that file is correctly created + expect(existsSync('bar')).toBe(true); + expect(existsSync('bar', PROJECT_DIR)).toBe(true); - // both files should parse fine now - expect(() => parseFile('foo', PROJECT_DIR, true, true)).not.toThrow(); - expect(() => parseFile('bar', PROJECT_DIR, true, true)).not.toThrow(); - }); -} + // both files should parse fine now + parseFile({ + filename: 'foo', + tmpDir: PROJECT_DIR, + relative: true, + ignoreTsconfigRootDir: true, + }); + parseFile({ + filename: 'bar', + tmpDir: PROJECT_DIR, + relative: true, + ignoreTsconfigRootDir: true, + }); + }); + } -describe('persistent parse', () => { describe('includes not ending in a slash', () => { const tsConfigExcludeBar = { include: ['src'], @@ -265,33 +506,59 @@ describe('persistent parse', () => { baseTests(tsConfigExcludeBar, tsConfigIncludeAll); - it('handles tsconfigs with no includes/excludes (single level)', () => { + describe('handles tsconfigs with no includes/excludes (single level)', () => { const PROJECT_DIR = setup({}, false); // parse once to: assert the config as correct, and to make sure the program is setup - expect(() => parseFile('foo', PROJECT_DIR)).not.toThrow(); - expect(() => parseFile('bar', PROJECT_DIR)).toThrow(); + parseFile({ + filename: 'foo', + tmpDir: PROJECT_DIR, + }); + parseFile({ + shouldThrowError: true, + filename: 'bar', + tmpDir: PROJECT_DIR, + }); // write a new file and attempt to parse it writeFile(PROJECT_DIR, 'bar'); - expect(() => parseFile('foo', PROJECT_DIR)).not.toThrow(); - expect(() => parseFile('bar', PROJECT_DIR)).not.toThrow(); + parseFile({ + filename: 'foo', + tmpDir: PROJECT_DIR, + }); + parseFile({ + filename: 'bar', + tmpDir: PROJECT_DIR, + }); }); - it('handles tsconfigs with no includes/excludes (nested)', () => { + describe('handles tsconfigs with no includes/excludes (nested)', () => { const PROJECT_DIR = setup({}, false); const bazSlashBar = 'baz/bar' as const; // parse once to: assert the config as correct, and to make sure the program is setup - expect(() => parseFile('foo', PROJECT_DIR)).not.toThrow(); - expect(() => parseFile(bazSlashBar, PROJECT_DIR)).toThrow(); + parseFile({ + filename: 'foo', + tmpDir: PROJECT_DIR, + }); + parseFile({ + shouldThrowError: true, + filename: bazSlashBar, + tmpDir: PROJECT_DIR, + }); // write a new file and attempt to parse it writeFile(PROJECT_DIR, bazSlashBar); - expect(() => parseFile('foo', PROJECT_DIR)).not.toThrow(); - expect(() => parseFile(bazSlashBar, PROJECT_DIR)).not.toThrow(); + parseFile({ + filename: 'foo', + tmpDir: PROJECT_DIR, + }); + parseFile({ + filename: bazSlashBar, + tmpDir: PROJECT_DIR, + }); }); }); @@ -331,13 +598,25 @@ describe('persistent parse', () => { const testNames = ['object', 'number', 'string', 'foo'] as const; for (const name of testNames) { - it(`first parse of ${name} should not throw`, () => { + describe(`first parse of ${name} should not throw`, () => { const PROJECT_DIR = setup(tsConfigIncludeAll); writeFile(PROJECT_DIR, name); - expect(() => parseFile(name, PROJECT_DIR)).not.toThrow(); + parseFile({ + filename: name, + tmpDir: PROJECT_DIR, + }); }); } }); } }); +} + +describe('persistent parse', () => { + describe('Builder Program', () => { + parserTests({ EXPERIMENTAL_useLanguageService: false }); + }); + describe('Language Service', () => { + parserTests({ EXPERIMENTAL_useLanguageService: true }); + }); }); diff --git a/packages/typescript-estree/tests/lib/semanticInfo.test.ts b/packages/typescript-estree/tests/lib/semanticInfo.test.ts index 3ebe689185e1..f4dad7562932 100644 --- a/packages/typescript-estree/tests/lib/semanticInfo.test.ts +++ b/packages/typescript-estree/tests/lib/semanticInfo.test.ts @@ -3,7 +3,7 @@ import glob from 'glob'; import * as path from 'path'; import * as ts from 'typescript'; -import { clearWatchCaches } from '../../src/create-program/getWatchProgramsForProjects'; +import { clearWatchCaches } from '../../src/create-program/shared'; import { createProgramFromConfigFile as createProgram } from '../../src/create-program/useProvidedPrograms'; import type { ParseAndGenerateServicesResult } from '../../src/parser'; import { parseAndGenerateServices } from '../../src/parser'; diff --git a/packages/website/src/components/linter/config.ts b/packages/website/src/components/linter/config.ts index f077f3786ee1..5263d7cdfd79 100644 --- a/packages/website/src/components/linter/config.ts +++ b/packages/website/src/components/linter/config.ts @@ -1,3 +1,4 @@ +import type { CanonicalPath } from '@site/../typescript-estree/dist/create-program/shared'; import type { ParseSettings } from '@typescript-eslint/typescript-estree/dist/parseSettings'; export const parseSettings: ParseSettings = { @@ -8,7 +9,7 @@ export const parseSettings: ParseSettings = { debugLevel: new Set(), errorOnUnknownASTType: false, extraFileExtensions: [], - filePath: '', + filePath: '' as CanonicalPath, jsx: false, loc: true, // eslint-disable-next-line no-console @@ -17,9 +18,10 @@ export const parseSettings: ParseSettings = { projects: [], range: true, tokens: [], - tsconfigRootDir: '/', + tsconfigRootDir: '/' as CanonicalPath, errorOnTypeScriptSyntacticAndSemanticIssues: false, EXPERIMENTAL_useSourceOfProjectReferenceRedirect: false, + EXPERIMENTAL_useLanguageService: false, singleRun: false, programs: null, moduleResolver: '',