diff --git a/docs/packages/TypeScript_ESTree.mdx b/docs/packages/TypeScript_ESTree.mdx index e8a27eb0f012..1ac35da35cd5 100644 --- a/docs/packages/TypeScript_ESTree.mdx +++ b/docs/packages/TypeScript_ESTree.mdx @@ -367,3 +367,7 @@ const { ast, services } = parseAndGenerateServices(code, { If you encounter a bug with the parser that you want to investigate, you can turn on the debug logging via setting the environment variable: `DEBUG=typescript-eslint:*`. I.e. in this repo you can run: `DEBUG=typescript-eslint:* yarn lint`. + +This will include TypeScript server logs. +To turn off these logs, include `-typescript-eslint:typescript-estree:tsserver:*` when setting the environment variable. +I.e. for this repo change to: `DEBUG='typescript-eslint:*,-typescript-eslint:typescript-estree:tsserver:*' yarn lint`. diff --git a/packages/types/src/parser-options.ts b/packages/types/src/parser-options.ts index 59eb090a4752..e270ce25198d 100644 --- a/packages/types/src/parser-options.ts +++ b/packages/types/src/parser-options.ts @@ -44,12 +44,22 @@ interface ProjectServiceOptions { * Globs of files to allow running with the default project compiler options * despite not being matched by the project service. */ - allowDefaultProject?: string[]; + allowDefaultProject?: string[] | undefined; /** * Path to a TSConfig to use instead of TypeScript's default project configuration. */ - defaultProject?: string; + defaultProject?: string | undefined | null; + + /** + * Maximum number of files to keep open with the project service. + */ + maximumOpenFiles?: number; + + /** + * Send changes to files as diffs instead of replacing the entire files. + */ + incremental?: boolean; /** * The maximum number of files {@link allowDefaultProject} may match. diff --git a/packages/typescript-estree/package.json b/packages/typescript-estree/package.json index 848a75f47d4d..d5e1762eb368 100644 --- a/packages/typescript-estree/package.json +++ b/packages/typescript-estree/package.json @@ -57,14 +57,17 @@ "@typescript-eslint/types": "7.13.0", "@typescript-eslint/visitor-keys": "7.13.0", "debug": "^4.3.4", + "diff": "^5.2.0", "globby": "^11.1.0", "is-glob": "^4.0.3", + "lru-cache": "^10.2.2", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^1.3.0" }, "devDependencies": { "@jest/types": "29.6.3", + "@types/diff": "^5.2.1", "glob": "*", "jest": "29.7.0", "prettier": "^3.2.5", diff --git a/packages/typescript-estree/src/create-program/createProjectService.ts b/packages/typescript-estree/src/create-program/createProjectService.ts index 9f0f44dcec68..252f2cec3ca0 100644 --- a/packages/typescript-estree/src/create-program/createProjectService.ts +++ b/packages/typescript-estree/src/create-program/createProjectService.ts @@ -1,32 +1,47 @@ /* eslint-disable @typescript-eslint/no-empty-function -- for TypeScript APIs*/ -import os from 'node:os'; +import path from 'node:path'; +import debug from 'debug'; import type * as ts from 'typescript/lib/tsserverlibrary'; import type { ProjectServiceOptions } from '../parser-options'; +import { getParsedConfigFile } from './getParsedConfigFile'; +import { + saveDirectoryWatchCallback, + saveFileWatchCallback, +} from './getWatchesForProjectService'; import { validateDefaultProjectForFilesGlob } from './validateDefaultProjectForFilesGlob'; -const DEFAULT_PROJECT_MATCHED_FILES_THRESHOLD = 8; +const log = debug('typescript-eslint:typescript-estree:createProjectService'); +const logTsserverErr = debug( + 'typescript-eslint:typescript-estree:tsserver:err', +); +const logTsserverInfo = debug( + 'typescript-eslint:typescript-estree:tsserver:info', +); +const logTsserverPerf = debug( + 'typescript-eslint:typescript-estree:tsserver:perf', +); +const logTsserverEvent = debug( + 'typescript-eslint:typescript-estree:tsserver:event', +); const doNothing = (): void => {}; -const createStubFileWatcher = (): ts.FileWatcher => ({ - close: doNothing, -}); - export type TypeScriptProjectService = ts.server.ProjectService; export interface ProjectServiceSettings { allowDefaultProject: string[] | undefined; maximumDefaultProjectFileMatchCount: number; service: TypeScriptProjectService; + maximumOpenFiles: number; + incremental: boolean; } export function createProjectService( - optionsRaw: boolean | ProjectServiceOptions | undefined, + options: Required, jsDocParsingMode: ts.JSDocParsingMode | undefined, ): ProjectServiceSettings { - const options = typeof optionsRaw === 'object' ? optionsRaw : {}; validateDefaultProjectForFilesGlob(options); // We import this lazily to avoid its cost for users who don't use the service @@ -34,81 +49,97 @@ export function createProjectService( // eslint-disable-next-line @typescript-eslint/no-require-imports const tsserver = require('typescript/lib/tsserverlibrary') as typeof ts; - // TODO: see getWatchProgramsForProjects - // We don't watch the disk, we just refer to these when ESLint calls us - // there's a whole separate update pass in maybeInvalidateProgram at the bottom of getWatchProgramsForProjects - // (this "goes nuclear on TypeScript") const system: ts.server.ServerHost = { ...tsserver.sys, clearImmediate, clearTimeout, setImmediate, setTimeout, - watchDirectory: createStubFileWatcher, - watchFile: createStubFileWatcher, + watchDirectory: saveDirectoryWatchCallback, + watchFile: saveFileWatchCallback, }; + const logger: ts.server.Logger = { + close: doNothing, + endGroup: doNothing, + getLogFileName: (): undefined => undefined, + // The debug library doesn't use levels without creating a namespace for each. + // Log levels are not passed to the writer so we wouldn't be able to forward + // to a respective namespace. Supporting would require an additional flag for + // granular control. Defaulting to all levels for now. + hasLevel: (): boolean => true, + info(s) { + this.msg(s, tsserver.server.Msg.Info); + }, + loggingEnabled: (): boolean => + // if none of the debug namespaces are enabled, then don't enable logging in tsserver + logTsserverInfo.enabled || + logTsserverErr.enabled || + logTsserverPerf.enabled, + msg: (s, type) => { + switch (type) { + case tsserver.server.Msg.Err: + logTsserverErr(s); + break; + case tsserver.server.Msg.Perf: + logTsserverPerf(s); + break; + default: + logTsserverInfo(s); + } + }, + perftrc(s) { + this.msg(s, tsserver.server.Msg.Perf); + }, + startGroup: doNothing, + }; + + log('Creating project service with: %o', options); + const service = new tsserver.server.ProjectService({ host: system, cancellationToken: { isCancellationRequested: (): boolean => false }, useSingleInferredProject: false, useInferredProjectPerProjectRoot: false, - logger: { - close: doNothing, - endGroup: doNothing, - getLogFileName: (): undefined => undefined, - hasLevel: (): boolean => false, - info: doNothing, - loggingEnabled: (): boolean => false, - msg: doNothing, - perftrc: doNothing, - startGroup: doNothing, - }, + logger, + eventHandler: logTsserverEvent.enabled + ? (e): void => { + logTsserverEvent(e); + } + : undefined, session: undefined, + canUseWatchEvents: true, jsDocParsingMode, }); if (options.defaultProject) { - let configRead; - + log('Enabling default project: %s', options.defaultProject); try { - configRead = tsserver.readConfigFile( + const configFile = getParsedConfigFile( options.defaultProject, - system.readFile, + path.dirname(options.defaultProject), + ); + service.setCompilerOptionsForInferredProjects( + // NOTE: The inferred projects API is not intended for source files when a tsconfig + // exists. There is no API that generates an InferredProjectCompilerOptions suggesting + // it is meant for hard coded options passed in. Hard casting as a work around. + // See https://github.com/microsoft/TypeScript/blob/27bcd4cb5a98bce46c9cdd749752703ead021a4b/src/server/protocol.ts#L1904 + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any + configFile.options as ts.server.protocol.InferredProjectCompilerOptions, ); } catch (error) { throw new Error( `Could not parse default project '${options.defaultProject}': ${(error as Error).message}`, ); } - - if (configRead.error) { - throw new Error( - `Could not read default project '${options.defaultProject}': ${tsserver.formatDiagnostic( - configRead.error, - { - getCurrentDirectory: system.getCurrentDirectory, - getCanonicalFileName: fileName => fileName, - getNewLine: () => os.EOL, - }, - )}`, - ); - } - - service.setCompilerOptionsForInferredProjects( - ( - configRead.config as { - compilerOptions: ts.server.protocol.InferredProjectCompilerOptions; - } - ).compilerOptions, - ); } return { allowDefaultProject: options.allowDefaultProject, + maximumOpenFiles: options.maximumOpenFiles, + incremental: options.incremental, maximumDefaultProjectFileMatchCount: - options.maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING ?? - DEFAULT_PROJECT_MATCHED_FILES_THRESHOLD, + options.maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING, service, }; } diff --git a/packages/typescript-estree/src/create-program/getParsedConfigFile.ts b/packages/typescript-estree/src/create-program/getParsedConfigFile.ts new file mode 100644 index 000000000000..de72cf743e8b --- /dev/null +++ b/packages/typescript-estree/src/create-program/getParsedConfigFile.ts @@ -0,0 +1,64 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import type * as ts from 'typescript/lib/tsserverlibrary'; + +import { CORE_COMPILER_OPTIONS } from './shared'; + +/** + * Utility offered by parser to help consumers parse a config file. + * + * @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 getParsedConfigFile( + configFile: string, + projectDirectory?: string, +): ts.ParsedCommandLine { + // 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 + // eslint-disable-next-line @typescript-eslint/no-require-imports + const tsserver = require('typescript/lib/tsserverlibrary') as typeof ts; + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (tsserver.sys === undefined) { + throw new Error( + '`createProgramFromConfigFile` is only supported in a Node-like environment.', + ); + } + + const parsed = tsserver.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: tsserver.sys.readDirectory, + readFile: file => fs.readFileSync(file, 'utf-8'), + useCaseSensitiveFileNames: tsserver.sys.useCaseSensitiveFileNames, + }, + ); + + // parsed is not undefined, since we throw on failure. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const result = parsed!; + if (result.errors.length) { + throw new Error(formatDiagnostics(result.errors)); + } + + return result; + + // scoped to parent function to use lazy typescript import + function formatDiagnostics(diagnostics: ts.Diagnostic[]): string | undefined { + return tsserver.formatDiagnostics(diagnostics, { + getCanonicalFileName: f => f, + getCurrentDirectory: process.cwd, + getNewLine: () => '\n', + }); + } +} + +export { getParsedConfigFile }; diff --git a/packages/typescript-estree/src/create-program/getWatchesForProjectService.ts b/packages/typescript-estree/src/create-program/getWatchesForProjectService.ts new file mode 100644 index 000000000000..a82356037dfb --- /dev/null +++ b/packages/typescript-estree/src/create-program/getWatchesForProjectService.ts @@ -0,0 +1,129 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import path from 'node:path'; + +import { debug } from 'debug'; +import * as ts from 'typescript'; + +const log = debug( + 'typescript-eslint:typescript-estree:getWatchesForProjectService', +); + +export class Watcher implements ts.FileWatcher { + constructor( + private readonly node: TrieNode, + public readonly callback: () => void, + ) {} + + close(): void { + log('closing %s', this.node.path); + this.node.value = null; + } +} + +export class TrieNode { + children: Map>; + value: T | null; + constructor(public readonly path: string) { + this.children = new Map(); + this.value = null; + } +} + +export class Trie { + root: TrieNode; + count: number; + + constructor() { + this.root = new TrieNode(''); + this.count = 1; + } + + insert(filePath: string): TrieNode { + // implicitly blocks a watch on the root of the file system + const parts = path.resolve(filePath).split(path.sep).slice(1); + const { currentNode } = parts.reduce( + ({ currentNode, rootPath }, part) => { + const currentPath = path.join(rootPath, part); + if (!currentNode.children.has(part)) { + currentNode.children.set(part, new TrieNode(currentPath)); + this.count++; + } + return { + currentNode: currentNode.children.get(part)!, + rootPath: currentPath, + }; + }, + { + currentNode: this.root, + rootPath: this.root.path, + }, + ); + log('Inserted (%d): %s', this.count, filePath); + return currentNode; + } + + get(filePath: string): TrieNode | null { + const parts = path.resolve(filePath).split(path.sep).slice(1); + const { lastNodeWithValue } = parts.reduce( + ({ currentNode, lastNodeWithValue }, part) => { + if (!currentNode.children.has(part)) { + return { currentNode: currentNode, lastNodeWithValue }; + } + const childNode = currentNode.children.get(part)!; + return { + currentNode: childNode, + lastNodeWithValue: + childNode.value != null ? childNode : lastNodeWithValue, + }; + }, + { + currentNode: this.root, + lastNodeWithValue: null as TrieNode | null, + }, + ); + log( + 'Retrieved (%d): %s: %s', + this.count, + filePath, + lastNodeWithValue?.path, + ); + return lastNodeWithValue; + } +} + +export const watches = new Trie(); + +export const saveFileWatchCallback = ( + path: string, + callback: ts.FileWatcherCallback, + _pollingInterval?: number, + _options?: ts.WatchOptions, +): ts.FileWatcher => { + const node = watches.insert(path); + if (node.value != null) { + return node.value; + } + const watcher = new Watcher(node, () => { + // edits are sent through script info, this is only used for new files + callback(path, ts.FileWatcherEventKind.Created, new Date()); + }); + node.value = watcher; + return watcher; +}; + +export const saveDirectoryWatchCallback = ( + path: string, + callback: ts.DirectoryWatcherCallback, + _recursive?: boolean, + _options?: ts.WatchOptions, +): ts.FileWatcher => { + const node = watches.insert(path); + if (node.value != null) { + return node.value; + } + const watcher = new Watcher(node, () => { + callback(path); + }); + node.value = watcher; + return watcher; +}; diff --git a/packages/typescript-estree/src/create-program/useProvidedPrograms.ts b/packages/typescript-estree/src/create-program/useProvidedPrograms.ts index cae07bda5fa1..e9d443a315a9 100644 --- a/packages/typescript-estree/src/create-program/useProvidedPrograms.ts +++ b/packages/typescript-estree/src/create-program/useProvidedPrograms.ts @@ -1,10 +1,10 @@ import debug from 'debug'; -import * as fs from 'fs'; import * as path from 'path'; import * as ts from 'typescript'; +import { getParsedConfigFile } from './getParsedConfigFile'; import type { ASTAndDefiniteProgram } from './shared'; -import { CORE_COMPILER_OPTIONS, getAstFromProgram } from './shared'; +import { getAstFromProgram } from './shared'; const log = debug('typescript-eslint:typescript-estree:useProvidedProgram'); @@ -56,44 +56,9 @@ function createProgramFromConfigFile( configFile: string, projectDirectory?: string, ): ts.Program { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - 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, - }, - ); - // parsed is not undefined, since we throw on failure. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const result = parsed!; - 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', - }); + const parsed = getParsedConfigFile(configFile, projectDirectory); + const host = ts.createCompilerHost(parsed.options, true); + return ts.createProgram(parsed.fileNames, parsed.options, host); } export { useProvidedPrograms, createProgramFromConfigFile }; diff --git a/packages/typescript-estree/src/parseSettings/createParseSettings.ts b/packages/typescript-estree/src/parseSettings/createParseSettings.ts index 98e42d8af36c..cb8dd3450268 100644 --- a/packages/typescript-estree/src/parseSettings/createParseSettings.ts +++ b/packages/typescript-estree/src/parseSettings/createParseSettings.ts @@ -4,7 +4,7 @@ import * as ts from 'typescript'; 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'; +import type { ProjectServiceOptions, TSESTreeOptions } from '../parser-options'; import { isSourceFile } from '../source-files'; import { DEFAULT_TSCONFIG_CACHE_DURATION_SECONDS, @@ -23,6 +23,9 @@ const log = debug( let TSCONFIG_MATCH_CACHE: ExpiringCache | null; let TSSERVER_PROJECT_SERVICE: ProjectServiceSettings | null = null; +const DEFAULT_PROJECT_MATCHED_FILES_THRESHOLD = 8; +const DEFAULT_PROJECT_OPENED_FILES_THRESHOLD = 16; + // 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 // https://github.com/microsoft/TypeScript/issues/56579 @@ -61,6 +64,17 @@ export function createParseSettings( return JSDocParsingMode.ParseAll; } })(); + const projectService: Required = { + allowDefaultProject: [], + defaultProject: null, + maximumOpenFiles: DEFAULT_PROJECT_OPENED_FILES_THRESHOLD, + incremental: false, + maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING: + DEFAULT_PROJECT_MATCHED_FILES_THRESHOLD, + ...(typeof tsestreeOptions.projectService === 'object' + ? tsestreeOptions.projectService + : {}), + }; const parseSettings: MutableParseSettings = { allowInvalidAST: tsestreeOptions.allowInvalidAST === true, @@ -108,7 +122,7 @@ export function createParseSettings( tsestreeOptions.projectService !== false && process.env.TYPESCRIPT_ESLINT_PROJECT_SERVICE === 'true') ? (TSSERVER_PROJECT_SERVICE ??= createProjectService( - tsestreeOptions.projectService, + projectService, jsDocParsingMode, )) : undefined, diff --git a/packages/typescript-estree/src/useProgramFromProjectService.ts b/packages/typescript-estree/src/useProgramFromProjectService.ts index b0606902b079..ba811253909e 100644 --- a/packages/typescript-estree/src/useProgramFromProjectService.ts +++ b/packages/typescript-estree/src/useProgramFromProjectService.ts @@ -1,10 +1,13 @@ import debug from 'debug'; +import { diffChars } from 'diff'; +import { LRUCache } from 'lru-cache'; import { minimatch } from 'minimatch'; import path from 'path'; -import { ScriptKind } from 'typescript'; +import * as ts from 'typescript'; import { createProjectProgram } from './create-program/createProjectProgram'; import type { ProjectServiceSettings } from './create-program/createProjectService'; +import { watches } from './create-program/getWatchesForProjectService'; import type { ASTAndDefiniteProgram } from './create-program/shared'; import { DEFAULT_PROJECT_FILES_ERROR_EXPLANATION } from './create-program/validateDefaultProjectForFilesGlob'; import type { MutableParseSettings } from './parseSettings'; @@ -12,50 +15,232 @@ import type { MutableParseSettings } from './parseSettings'; const log = debug( 'typescript-eslint:typescript-estree:useProgramFromProjectService', ); +const logEdits = debug( + 'typescript-eslint:typescript-estree:useProgramFromProjectService:editContent', +); + +const getOrCreateOpenedFilesCache = ( + service: ts.server.ProjectService & { + __opened_lru_cache?: Map; + }, + options: { + max: number; + }, +): Map => { + if (!service.__opened_lru_cache) { + service.__opened_lru_cache = new LRUCache< + string, + ts.server.OpenConfiguredProjectResult + >({ + max: options.max, + dispose: (_, key): void => { + log(`Closing project service file: ${key}`); + service.closeClientFile(key); + }, + }); + } + return service.__opened_lru_cache; +}; + +const union = (self: Set, other: Set): Set => + new Set([...self, ...other]); +const difference = (self: Set, other: Set): Set => + new Set([...self].filter(elem => !other.has(elem))); +const symmetricDifference = (self: Set, other: Set): Set => + union(difference(self, other), difference(other, self)); + +const updateExtraFileExtensions = ( + service: ts.server.ProjectService & { + __extra_file_extensions?: Set; + }, + extraFileExtensions: string[], +): void => { + const uniqExtraFileExtensions = new Set(extraFileExtensions); + if ( + (service.__extra_file_extensions === undefined && + uniqExtraFileExtensions.size > 0) || + (service.__extra_file_extensions !== undefined && + symmetricDifference( + service.__extra_file_extensions, + uniqExtraFileExtensions, + ).size > 0) + ) { + log( + 'Updating extra file extensions: %s: %s', + service.__extra_file_extensions, + uniqExtraFileExtensions, + ); + service.setHostConfiguration({ + extraFileExtensions: [...uniqExtraFileExtensions].map(extension => ({ + extension, + isMixedContent: false, + scriptKind: ts.ScriptKind.Deferred, + })), + }); + service.__extra_file_extensions = uniqExtraFileExtensions; + log('Extra file extensions updated: %o', service.__extra_file_extensions); + } +}; + +const filePathMatchedByConfiguredProject = ( + service: ts.server.ProjectService, + filePath: string, +): boolean => { + const configuredProjects = service.configuredProjects; + for (const project of configuredProjects.values()) { + if (project.containsFile(filePath as ts.server.NormalizedPath)) { + return true; + } + } + return false; +}; + +interface ContentEdit { + start: number; + end: number; + content: string; +} + +const makeEdits = (oldContent: string, newContent: string): ContentEdit[] => { + const changes = diffChars(oldContent, newContent); + const edits: ContentEdit[] = []; + + let offset = 0; + changes.forEach(change => { + if (change.count === undefined) { + return; + } + edits.push({ + start: offset, + end: change.added ? offset : offset + change.count, + content: change.removed ? '' : change.value, + }); + if (!change.removed) { + offset += change.count; + } + }); + return edits; +}; export function useProgramFromProjectService( { allowDefaultProject, maximumDefaultProjectFileMatchCount, + maximumOpenFiles, + incremental, service, }: ProjectServiceSettings, parseSettings: Readonly, hasFullTypeInformation: boolean, defaultProjectMatchedFiles: Set, ): ASTAndDefiniteProgram | undefined { + // NOTE: triggers a full project reload when changes are detected + updateExtraFileExtensions(service, parseSettings.extraFileExtensions); + + const openedFilesCache = getOrCreateOpenedFilesCache(service, { + max: maximumOpenFiles, + }); + // We don't canonicalize the filename because it caused a performance regression. // See https://github.com/typescript-eslint/typescript-eslint/issues/8519 const filePathAbsolute = absolutify(parseSettings.filePath); + log( 'Opening project service file for: %s at absolute path %s', parseSettings.filePath, filePathAbsolute, ); - if (parseSettings.extraFileExtensions.length) { - service.setHostConfiguration({ - extraFileExtensions: parseSettings.extraFileExtensions.map(extension => ({ - extension, - isMixedContent: false, - scriptKind: ScriptKind.Deferred, - })), - }); + const isOpened = openedFilesCache.has(filePathAbsolute); + if (!isOpened) { + if (!filePathMatchedByConfiguredProject(service, filePathAbsolute)) { + log('Orphaned file: %s', filePathAbsolute); + const watcher = watches.get(filePathAbsolute); + if (watcher?.value != null) { + log('Triggering watcher: %s', watcher.path); + watcher.value.callback(); + } else { + log('No watcher found for: %s', filePathAbsolute); + } + } } - const opened = service.openClientFile( + const isFileInConfiguredProject = filePathMatchedByConfiguredProject( + service, filePathAbsolute, - parseSettings.codeFullText, - /* scriptKind */ undefined, - parseSettings.tsconfigRootDir, ); - log('Opened project service file: %o', opened); + // when reusing an openClientFile handler, we need to ensure that + // the file is still open and manually update its contents + const cachedScriptInfo = !isOpened + ? undefined + : service.getScriptInfo(filePathAbsolute); + + if (cachedScriptInfo) { + log( + 'File already opened, sending changes to tsserver: %s', + filePathAbsolute, + ); + + const snapshot = cachedScriptInfo.getSnapshot(); + const edits = incremental + ? makeEdits( + snapshot.getText(0, snapshot.getLength()), + parseSettings.codeFullText, + ) + : [ + { + start: 0, + end: snapshot.getLength(), + content: parseSettings.codeFullText, + }, + ]; + + edits.forEach(({ start, end, content }) => { + logEdits( + 'Sending %s edit for: %s: %o', + incremental ? 'incremental' : 'full', + filePathAbsolute, + { + start, + end, + content, + }, + ); + cachedScriptInfo.editContent(start, end, content); + }); + } + + const opened = isOpened + ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + openedFilesCache.get(filePathAbsolute)! + : service.openClientFile( + filePathAbsolute, + parseSettings.codeFullText, + /* scriptKind */ undefined, + parseSettings.tsconfigRootDir, + ); + + if (!isOpened) { + openedFilesCache.set(filePathAbsolute, opened); + } + + log( + '%s (%s/%s): %o', + isOpened + ? 'Reusing project service file from cache' + : 'Opened project service file', + service.openFiles.size, + maximumOpenFiles, + opened, + ); if (hasFullTypeInformation) { log( 'Project service type information enabled; checking for file path match on: %o', allowDefaultProject, ); + const isDefaultProjectAllowedPath = filePathMatchedBy( parseSettings.filePath, allowDefaultProject, @@ -67,21 +252,22 @@ export function useProgramFromProjectService( opened.configFileName, ); - if (opened.configFileName) { - if (isDefaultProjectAllowedPath) { - throw new Error( - `${parseSettings.filePath} was included by allowDefaultProject but also was found in the project service. Consider removing it from allowDefaultProject.`, - ); - } + if (isFileInConfiguredProject && isDefaultProjectAllowedPath) { + throw new Error( + `${parseSettings.filePath} was included by allowDefaultProject but also was found in the project service. Consider removing it from allowDefaultProject.`, + ); } else if (!isDefaultProjectAllowedPath) { throw new Error( `${parseSettings.filePath} was not found by the project service. Consider either including it in the tsconfig.json or including it in allowDefaultProject.`, ); } } + log('Retrieving script info and then program for: %s', filePathAbsolute); - const scriptInfo = service.getScriptInfo(filePathAbsolute); + const scriptInfo = + cachedScriptInfo ?? service.getScriptInfo(filePathAbsolute); + /* eslint-disable @typescript-eslint/no-non-null-assertion */ const program = service .getDefaultProjectForFile(scriptInfo!.fileName, true)! diff --git a/packages/typescript-estree/tests/fixtures/projectServicesComplex/project-a/src/index.ts b/packages/typescript-estree/tests/fixtures/projectServicesComplex/project-a/src/index.ts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/typescript-estree/tests/fixtures/projectServicesComplex/project-a/tsconfig.json b/packages/typescript-estree/tests/fixtures/projectServicesComplex/project-a/tsconfig.json new file mode 100644 index 000000000000..144eca86d81d --- /dev/null +++ b/packages/typescript-estree/tests/fixtures/projectServicesComplex/project-a/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../tsconfig.base.json", + "include": [], + "references": [{ "path": "./tsconfig.src.json" }] +} diff --git a/packages/typescript-estree/tests/fixtures/projectServicesComplex/project-a/tsconfig.src.json b/packages/typescript-estree/tests/fixtures/projectServicesComplex/project-a/tsconfig.src.json new file mode 100644 index 000000000000..35eec1467e18 --- /dev/null +++ b/packages/typescript-estree/tests/fixtures/projectServicesComplex/project-a/tsconfig.src.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.base.json", + "include": ["./src"], + "compilerOptions": { + "paths": { + "@": ["./project-b/src/index.ts"] + } + } +} diff --git a/packages/typescript-estree/tests/fixtures/projectServicesComplex/project-b/src/index.ts b/packages/typescript-estree/tests/fixtures/projectServicesComplex/project-b/src/index.ts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/typescript-estree/tests/fixtures/projectServicesComplex/project-b/tsconfig.json b/packages/typescript-estree/tests/fixtures/projectServicesComplex/project-b/tsconfig.json new file mode 100644 index 000000000000..144eca86d81d --- /dev/null +++ b/packages/typescript-estree/tests/fixtures/projectServicesComplex/project-b/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../tsconfig.base.json", + "include": [], + "references": [{ "path": "./tsconfig.src.json" }] +} diff --git a/packages/typescript-estree/tests/fixtures/projectServicesComplex/project-b/tsconfig.src.json b/packages/typescript-estree/tests/fixtures/projectServicesComplex/project-b/tsconfig.src.json new file mode 100644 index 000000000000..35eec1467e18 --- /dev/null +++ b/packages/typescript-estree/tests/fixtures/projectServicesComplex/project-b/tsconfig.src.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.base.json", + "include": ["./src"], + "compilerOptions": { + "paths": { + "@": ["./project-b/src/index.ts"] + } + } +} diff --git a/packages/typescript-estree/tests/fixtures/projectServicesComplex/tsconfig.base.json b/packages/typescript-estree/tests/fixtures/projectServicesComplex/tsconfig.base.json new file mode 100644 index 000000000000..1558f0441589 --- /dev/null +++ b/packages/typescript-estree/tests/fixtures/projectServicesComplex/tsconfig.base.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "strict": true, + "paths": { + "@/package-a": ["./project-a/src/index.ts"], + "@/package-b": ["./project-b/src/index.ts"] + } + } +} diff --git a/packages/typescript-estree/tests/fixtures/projectServicesComplex/tsconfig.json b/packages/typescript-estree/tests/fixtures/projectServicesComplex/tsconfig.json new file mode 100644 index 000000000000..f5573dac6eda --- /dev/null +++ b/packages/typescript-estree/tests/fixtures/projectServicesComplex/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.base.json", + "include": [], + "references": [ + { "path": "./packages/package-a" }, + { "path": "./packages/package-b" } + ] +} diff --git a/packages/typescript-estree/tests/fixtures/projectServicesComplex/tsconfig.overridden.json b/packages/typescript-estree/tests/fixtures/projectServicesComplex/tsconfig.overridden.json new file mode 100644 index 000000000000..588708a2b74e --- /dev/null +++ b/packages/typescript-estree/tests/fixtures/projectServicesComplex/tsconfig.overridden.json @@ -0,0 +1,3 @@ +{ + "extends": ["./tsconfig.base.json", "./tsconfig.overrides.json"] +} diff --git a/packages/typescript-estree/tests/fixtures/projectServicesComplex/tsconfig.overrides.json b/packages/typescript-estree/tests/fixtures/projectServicesComplex/tsconfig.overrides.json new file mode 100644 index 000000000000..8ad9c98014c7 --- /dev/null +++ b/packages/typescript-estree/tests/fixtures/projectServicesComplex/tsconfig.overrides.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "strict": false + } +} diff --git a/packages/typescript-estree/tests/lib/createProjectService.test.ts b/packages/typescript-estree/tests/lib/createProjectService.test.ts index 1159e8625f41..cec596cd8c4a 100644 --- a/packages/typescript-estree/tests/lib/createProjectService.test.ts +++ b/packages/typescript-estree/tests/lib/createProjectService.test.ts @@ -1,22 +1,60 @@ +import fs from 'node:fs'; +import { join } from 'node:path'; + +import debug from 'debug'; import * as ts from 'typescript'; import { createProjectService } from '../../src/create-program/createProjectService'; +import { CORE_COMPILER_OPTIONS } from '../../src/create-program/shared'; -const mockReadConfigFile = jest.fn(); -const mockSetCompilerOptionsForInferredProjects = jest.fn(); +const FIXTURES_DIR = join(__dirname, '../fixtures/projectServicesComplex'); -jest.mock('typescript/lib/tsserverlibrary', () => ({ - ...jest.requireActual('typescript/lib/tsserverlibrary'), - readConfigFile: mockReadConfigFile, - server: { - ProjectService: class { - setCompilerOptionsForInferredProjects = - mockSetCompilerOptionsForInferredProjects; - }, - }, -})); +const origSys = ts.sys; +const origGetParsedCommandLineOfConfigFile = + ts.getParsedCommandLineOfConfigFile; + +let mockSys: typeof ts.sys; +const mockGetParsedCommandLineOfConfigFile = jest.fn(); +const mockSetCompilerOptionsForInferredProjects = jest.fn(); describe('createProjectService', () => { + beforeEach(() => { + mockSys = origSys; + // doMock is used over mock to handle the tsserver.sys property mock + jest.doMock('typescript/lib/tsserverlibrary', () => { + return { + ...jest.requireActual('typescript/lib/tsserverlibrary'), + sys: mockSys, + getParsedCommandLineOfConfigFile: mockGetParsedCommandLineOfConfigFile, + server: { + ...jest.requireActual('typescript/lib/tsserverlibrary').server, + ProjectService: class { + logger: ts.server.Logger; + eventHandler: ts.server.ProjectServiceEventHandler | undefined; + constructor( + ...args: ConstructorParameters + ) { + this.logger = args[0].logger; + this.eventHandler = args[0].eventHandler; + if (this.eventHandler) { + this.eventHandler({ + eventName: 'projectLoadingStart', + } as ts.server.ProjectLoadingStartEvent); + } + } + setCompilerOptionsForInferredProjects = + mockSetCompilerOptionsForInferredProjects; + }, + }, + }; + }); + }); + + afterEach(() => { + jest.resetAllMocks(); + jest.resetModules(); + }); + it('sets allowDefaultProject when options.allowDefaultProject is defined', () => { const allowDefaultProject = ['./*.js']; const settings = createProjectService({ allowDefaultProject }, undefined); @@ -30,18 +68,20 @@ describe('createProjectService', () => { expect(settings.allowDefaultProject).toBeUndefined(); }); - it('throws an error when options.defaultProject is set and readConfigFile returns an error', () => { - mockReadConfigFile.mockReturnValue({ - error: { - category: ts.DiagnosticCategory.Error, - code: 1234, - file: ts.createSourceFile('./tsconfig.json', '', { - languageVersion: ts.ScriptTarget.Latest, - }), - start: 0, - length: 0, - messageText: 'Oh no!', - } satisfies ts.Diagnostic, + it('throws an error when options.defaultProject is set and getParsedCommandLineOfConfigFile returns an error', () => { + mockGetParsedCommandLineOfConfigFile.mockReturnValue({ + errors: [ + { + category: ts.DiagnosticCategory.Error, + code: 1234, + file: ts.createSourceFile('./tsconfig.json', '', { + languageVersion: ts.ScriptTarget.Latest, + }), + start: 0, + length: 0, + messageText: 'Oh no!', + }, + ] satisfies ts.Diagnostic[], }); expect(() => @@ -53,40 +93,264 @@ describe('createProjectService', () => { undefined, ), ).toThrow( - /Could not read default project '\.\/tsconfig.json': .+ error TS1234: Oh no!/, + /Could not parse default project '\.\/tsconfig.json': .+ error TS1234: Oh no!/, ); }); - it('throws an error when options.defaultProject is set and readConfigFile throws an error', () => { - mockReadConfigFile.mockImplementation(() => { - throw new Error('Oh no!'); + it('throws an error when options.defaultProject is absolute and getParsedCommandLineOfConfigFile returns an error', () => { + mockGetParsedCommandLineOfConfigFile.mockReturnValue({ + errors: [ + { + category: ts.DiagnosticCategory.Error, + code: 1234, + // absolute path triggers getCanonicalFileName for coverage + file: ts.createSourceFile('/tsconfig.json', '', { + languageVersion: ts.ScriptTarget.Latest, + }), + start: 0, + length: 0, + messageText: 'Oh no!', + }, + ] satisfies ts.Diagnostic[], }); expect(() => createProjectService( { allowDefaultProject: ['file.js'], - defaultProject: './tsconfig.json', + defaultProject: '/tsconfig.json', }, undefined, ), - ).toThrow("Could not parse default project './tsconfig.json': Oh no!"); + ).toThrow( + /Could not parse default project '\/tsconfig.json': .+ error TS1234: Oh no!/, + ); }); - it('uses the default projects compiler options when options.defaultProject is set and readConfigFile succeeds', () => { - const compilerOptions = { strict: true }; - mockReadConfigFile.mockReturnValue({ config: { compilerOptions } }); + it('throws an error when options.defaultProject is set and getParsedCommandLineOfConfigFile throws an error', () => { + mockGetParsedCommandLineOfConfigFile.mockImplementation(() => { + throw new Error('Oh no!'); + }); + + expect(() => { + return createProjectService( + { + allowDefaultProject: ['file.js'], + defaultProject: './tsconfig.json', + }, + undefined, + ); + }).toThrow("Could not parse default project './tsconfig.json': Oh no!"); + }); + it('throws an error when options.defaultProject is set and tsserver.sys is undefined', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockSys = undefined as any; + expect(() => { + return createProjectService( + { + allowDefaultProject: ['file.js'], + defaultProject: './tsconfig.json', + }, + undefined, + ); + }).toThrow( + "Could not parse default project './tsconfig.json': `createProgramFromConfigFile` is only supported in a Node-like environment.", + ); + }); + + it('uses the default projects compiler options when options.defaultProject is set and getParsedCommandLineOfConfigFile succeeds', () => { + mockGetParsedCommandLineOfConfigFile.mockImplementation( + origGetParsedCommandLineOfConfigFile, + ); + + const base = JSON.parse( + fs.readFileSync(join(FIXTURES_DIR, 'tsconfig.base.json'), 'utf8'), + ); + const compilerOptions = { + ...CORE_COMPILER_OPTIONS, + ...base?.compilerOptions, + }; + const { service } = createProjectService( + { + defaultProject: join(FIXTURES_DIR, 'tsconfig.base.json'), + }, + undefined, + ); + + expect(service.setCompilerOptionsForInferredProjects).toHaveBeenCalledWith( + // looser assertion since config parser adds metadata to track references to other files + expect.objectContaining(compilerOptions), + ); + }); + + it('uses the default projects extended compiler options when options.defaultProject is set and getParsedCommandLineOfConfigFile succeeds', () => { + mockGetParsedCommandLineOfConfigFile.mockImplementation( + origGetParsedCommandLineOfConfigFile, + ); + + const base = JSON.parse( + fs.readFileSync(join(FIXTURES_DIR, 'tsconfig.base.json'), 'utf8'), + ); + const compilerOptions = { + ...CORE_COMPILER_OPTIONS, + ...base?.compilerOptions, + }; const { service } = createProjectService( { - allowDefaultProject: ['file.js'], - defaultProject: './tsconfig.json', + defaultProject: join(FIXTURES_DIR, 'tsconfig.json'), }, undefined, ); expect(service.setCompilerOptionsForInferredProjects).toHaveBeenCalledWith( - compilerOptions, + // looser assertion since config parser adds metadata to track references to other files + expect.objectContaining(compilerOptions), ); }); + + it('uses the default projects multiple extended compiler options when options.defaultProject is set and getParsedCommandLineOfConfigFile succeeds', () => { + mockGetParsedCommandLineOfConfigFile.mockImplementation( + origGetParsedCommandLineOfConfigFile, + ); + + const base = JSON.parse( + fs.readFileSync(join(FIXTURES_DIR, 'tsconfig.base.json'), 'utf8'), + ); + const overrides = JSON.parse( + fs.readFileSync(join(FIXTURES_DIR, 'tsconfig.overrides.json'), 'utf8'), + ); + const compilerOptions = { + ...CORE_COMPILER_OPTIONS, + ...base?.compilerOptions, + ...overrides?.compilerOptions, + }; + const { service } = createProjectService( + { + // extends tsconfig.base.json and tsconfig.overrides.json + defaultProject: join(FIXTURES_DIR, 'tsconfig.overridden.json'), + }, + undefined, + ); + + expect(service.setCompilerOptionsForInferredProjects).toHaveBeenCalledWith( + // looser assertion since config parser adds metadata to track references to other files + expect.objectContaining(compilerOptions), + ); + }); + + it('uses the default projects error debugger for error messages when enabled', () => { + jest.spyOn(process.stderr, 'write').mockImplementation(); + const { service } = createProjectService(undefined, undefined); + debug.enable('typescript-eslint:typescript-estree:tsserver:err'); + const enabled = service.logger.loggingEnabled(); + service.logger.msg('foo', ts.server.Msg.Err); + debug.disable(); + + expect(enabled).toBe(true); + expect(process.stderr.write).toHaveBeenCalledWith( + expect.stringMatching( + /^.*typescript-eslint:typescript-estree:tsserver:err foo\n$/, + ), + ); + }); + + it('does not use the default projects error debugger for error messages when disabled', () => { + jest.spyOn(process.stderr, 'write').mockImplementation(); + const { service } = createProjectService(undefined, undefined); + const enabled = service.logger.loggingEnabled(); + service.logger.msg('foo', ts.server.Msg.Err); + + expect(enabled).toBe(false); + expect(process.stderr.write).toHaveBeenCalledTimes(0); + }); + + it('uses the default projects info debugger for info messages when enabled', () => { + jest.spyOn(process.stderr, 'write').mockImplementation(); + const { service } = createProjectService(undefined, undefined); + debug.enable('typescript-eslint:typescript-estree:tsserver:info'); + const enabled = service.logger.loggingEnabled(); + service.logger.info('foo'); + debug.disable(); + + expect(enabled).toBe(true); + expect(process.stderr.write).toHaveBeenCalledWith( + expect.stringMatching( + /^.*typescript-eslint:typescript-estree:tsserver:info foo\n$/, + ), + ); + }); + + it('does not use the default projects info debugger for info messages when disabled', () => { + jest.spyOn(process.stderr, 'write').mockImplementation(); + const { service } = createProjectService(undefined, undefined); + const enabled = service.logger.loggingEnabled(); + service.logger.info('foo'); + + expect(enabled).toBe(false); + expect(process.stderr.write).toHaveBeenCalledTimes(0); + }); + + it('uses the default projects perf debugger for perf messages when enabled', () => { + jest.spyOn(process.stderr, 'write').mockImplementation(); + const { service } = createProjectService(undefined, undefined); + debug.enable('typescript-eslint:typescript-estree:tsserver:perf'); + const enabled = service.logger.loggingEnabled(); + service.logger.perftrc('foo'); + debug.disable(); + + expect(enabled).toBe(true); + expect(process.stderr.write).toHaveBeenCalledWith( + expect.stringMatching( + /^.*typescript-eslint:typescript-estree:tsserver:perf foo\n$/, + ), + ); + }); + + it('does not use the default projects perf debugger for perf messages when disabled', () => { + jest.spyOn(process.stderr, 'write').mockImplementation(); + const { service } = createProjectService(undefined, undefined); + const enabled = service.logger.loggingEnabled(); + service.logger.perftrc('foo'); + + expect(enabled).toBe(false); + expect(process.stderr.write).toHaveBeenCalledTimes(0); + }); + + it('enables all log levels for the default projects logger', () => { + const { service } = createProjectService(undefined, undefined); + + expect(service.logger.hasLevel(ts.server.LogLevel.terse)).toBe(true); + expect(service.logger.hasLevel(ts.server.LogLevel.normal)).toBe(true); + expect(service.logger.hasLevel(ts.server.LogLevel.requestTime)).toBe(true); + expect(service.logger.hasLevel(ts.server.LogLevel.verbose)).toBe(true); + }); + + it('does not return a log filename with the default projects logger', () => { + const { service } = createProjectService(undefined, undefined); + + expect(service.logger.getLogFileName()).toBeUndefined(); + }); + + it('uses the default projects event debugger for event handling when enabled', () => { + jest.spyOn(process.stderr, 'write').mockImplementation(); + + debug.enable('typescript-eslint:typescript-estree:tsserver:event'); + createProjectService(undefined, undefined); + debug.disable(); + + expect(process.stderr.write).toHaveBeenCalledWith( + expect.stringMatching( + /^.*typescript-eslint:typescript-estree:tsserver:event { eventName: 'projectLoadingStart' }\n$/, + ), + ); + }); + + it('does not use the default projects event debugger for event handling when disabled', () => { + jest.spyOn(process.stderr, 'write').mockImplementation(); + + createProjectService(undefined, undefined); + + expect(process.stderr.write).toHaveBeenCalledTimes(0); + }); }); diff --git a/packages/typescript-estree/tests/lib/useProgramFromProjectService.test.ts b/packages/typescript-estree/tests/lib/useProgramFromProjectService.test.ts index 3ca8ae53aea4..ee2de7594d11 100644 --- a/packages/typescript-estree/tests/lib/useProgramFromProjectService.test.ts +++ b/packages/typescript-estree/tests/lib/useProgramFromProjectService.test.ts @@ -344,4 +344,88 @@ If you absolutely need more files included, set parserOptions.projectService.max ], }); }); + + it('does not call setHostConfiguration on the service to use extraFileExtensions when unchanged', () => { + const { service } = createMockProjectService(); + const settings = createProjectServiceSettings({ + allowDefaultProject: [mockParseSettings.filePath], + service, + }); + + useProgramFromProjectService( + settings, + { + ...mockParseSettings, + extraFileExtensions: ['.vue'], + }, + false, + new Set(), + ); + + expect(service.setHostConfiguration).toHaveBeenCalledTimes(1); + expect(service.setHostConfiguration).toHaveBeenCalledWith({ + extraFileExtensions: [ + { + extension: '.vue', + isMixedContent: false, + scriptKind: ScriptKind.Deferred, + }, + ], + }); + + useProgramFromProjectService( + settings, + { + ...mockParseSettings, + extraFileExtensions: ['.vue'], + }, + false, + new Set(), + ); + expect(service.setHostConfiguration).toHaveBeenCalledTimes(1); + }); + + it('calls setHostConfiguration on the service to use extraFileExtensions when changed', () => { + const { service } = createMockProjectService(); + const settings = createProjectServiceSettings({ + allowDefaultProject: [mockParseSettings.filePath], + service, + }); + + useProgramFromProjectService( + settings, + { + ...mockParseSettings, + extraFileExtensions: ['.vue'], + }, + false, + new Set(), + ); + + expect(service.setHostConfiguration).toHaveBeenCalledTimes(1); + expect(service.setHostConfiguration).toHaveBeenCalledWith({ + extraFileExtensions: [ + { + extension: '.vue', + isMixedContent: false, + scriptKind: ScriptKind.Deferred, + }, + ], + }); + + useProgramFromProjectService( + settings, + { + ...mockParseSettings, + extraFileExtensions: [], + }, + false, + new Set(), + ); + + expect(service.setHostConfiguration).toHaveBeenCalledTimes(2); + expect(service.setHostConfiguration).toHaveBeenCalledWith({ + extraFileExtensions: [], + }); + }); }); diff --git a/yarn.lock b/yarn.lock index 2c0521519afd..1915acfef7eb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5104,6 +5104,13 @@ __metadata: languageName: node linkType: hard +"@types/diff@npm:^5.2.1": + version: 5.2.1 + resolution: "@types/diff@npm:5.2.1" + checksum: 5983a323177bd691cb2194f5d55b960cd20a9c8fec653b4b038760c5809627cc9ea3578fdf10119ccbefefef193ea925f2817136eb97b17388f66b16c8480a8a + languageName: node + linkType: hard + "@types/eslint-scope@link:./tools/dummypkg::locator=%40typescript-eslint%2Ftypescript-eslint%40workspace%3A.": version: 0.0.0-use.local resolution: "@types/eslint-scope@link:./tools/dummypkg::locator=%40typescript-eslint%2Ftypescript-eslint%40workspace%3A." @@ -5936,13 +5943,16 @@ __metadata: resolution: "@typescript-eslint/typescript-estree@workspace:packages/typescript-estree" dependencies: "@jest/types": 29.6.3 + "@types/diff": ^5.2.1 "@typescript-eslint/types": 7.13.0 "@typescript-eslint/visitor-keys": 7.13.0 debug: ^4.3.4 + diff: ^5.2.0 glob: "*" globby: ^11.1.0 is-glob: ^4.0.3 jest: 29.7.0 + lru-cache: ^10.2.2 minimatch: ^9.0.4 prettier: ^3.2.5 rimraf: "*" @@ -9098,6 +9108,13 @@ __metadata: languageName: node linkType: hard +"diff@npm:^5.2.0": + version: 5.2.0 + resolution: "diff@npm:5.2.0" + checksum: 12b63ca9c36c72bafa3effa77121f0581b4015df18bc16bac1f8e263597735649f1a173c26f7eba17fb4162b073fee61788abe49610e6c70a2641fe1895443fd + languageName: node + linkType: hard + "dir-glob@npm:^3.0.1": version: 3.0.1 resolution: "dir-glob@npm:3.0.1" @@ -13962,6 +13979,13 @@ __metadata: languageName: node linkType: hard +"lru-cache@npm:^10.2.2": + version: 10.2.2 + resolution: "lru-cache@npm:10.2.2" + checksum: 98e8fc93691c546f719a76103ef2bee5a3ac823955c755a47641ec41f8c7fafa1baeaba466937cc1cbfa9cfd47e03536d10e2db3158a64ad91ff3a58a32c893e + languageName: node + linkType: hard + "lru-cache@npm:^5.1.1": version: 5.1.1 resolution: "lru-cache@npm:5.1.1"