From e1eaccedeb6af82427c76f90443d7eed9dfd53a7 Mon Sep 17 00:00:00 2001 From: "Lyu, Wei-Da" <36730922+jasonlyu123@users.noreply.github.com> Date: Fri, 12 Jan 2024 00:39:10 +0800 Subject: [PATCH] perf: auto import cache (#2237) Add `typescript-auto-import-cache` to more cache auto imports more reliably #2232 #2193 --- packages/language-server/package.json | 1 + packages/language-server/src/ls-config.ts | 5 + .../plugins/typescript/LSAndTSDocResolver.ts | 130 ++++++++++++--- .../src/plugins/typescript/SnapshotManager.ts | 96 +----------- .../src/plugins/typescript/module-loader.ts | 3 +- .../src/plugins/typescript/service.ts | 148 +++++++++++++----- .../src/plugins/typescript/serviceCache.ts | 93 +++++++++++ .../features/CompletionProvider.test.ts | 125 ++++++++++++++- .../test/plugins/typescript/service.test.ts | 3 +- pnpm-lock.yaml | 9 ++ 10 files changed, 454 insertions(+), 159 deletions(-) create mode 100644 packages/language-server/src/plugins/typescript/serviceCache.ts diff --git a/packages/language-server/package.json b/packages/language-server/package.json index fc66d7b54..e15bc971d 100644 --- a/packages/language-server/package.json +++ b/packages/language-server/package.json @@ -58,6 +58,7 @@ "svelte-preprocess": "~5.1.0", "svelte2tsx": "workspace:~", "typescript": "^5.3.2", + "typescript-auto-import-cache": "^0.3.2", "vscode-css-languageservice": "~6.2.10", "vscode-html-languageservice": "~5.1.1", "vscode-languageserver": "8.0.2", diff --git a/packages/language-server/src/ls-config.ts b/packages/language-server/src/ls-config.ts index 1511488b6..42de751ea 100644 --- a/packages/language-server/src/ls-config.ts +++ b/packages/language-server/src/ls-config.ts @@ -440,6 +440,11 @@ export class LSConfigManager { config.suggest?.objectLiteralMethodSnippets?.enabled ?? true, preferTypeOnlyAutoImports: config.preferences?.preferTypeOnlyAutoImports, + // Although we don't support incompletion cache. + // But this will make ts resolve the module specifier more aggressively + // Which also makes the completion label detail show up in more cases + allowIncompleteCompletions: true, + includeInlayEnumMemberValueHints: inlayHints?.enumMemberValues?.enabled, includeInlayFunctionLikeReturnTypeHints: inlayHints?.functionLikeReturnTypes?.enabled, includeInlayParameterNameHints: inlayHints?.parameterNames?.enabled, diff --git a/packages/language-server/src/plugins/typescript/LSAndTSDocResolver.ts b/packages/language-server/src/plugins/typescript/LSAndTSDocResolver.ts index 2d438cedb..c7c3078aa 100644 --- a/packages/language-server/src/plugins/typescript/LSAndTSDocResolver.ts +++ b/packages/language-server/src/plugins/typescript/LSAndTSDocResolver.ts @@ -1,4 +1,4 @@ -import { dirname } from 'path'; +import { dirname, join } from 'path'; import ts from 'typescript'; import { TextDocumentContentChangeEvent } from 'vscode-languageserver'; import { Document, DocumentManager } from '../../lib/documents'; @@ -19,8 +19,10 @@ import { LanguageServiceContainer, LanguageServiceDocumentContext } from './service'; +import { createProjectService } from './serviceCache'; import { GlobalSnapshotsManager, SnapshotManager } from './SnapshotManager'; import { isSubPath } from './utils'; +import { FileMap } from '../../lib/documents/fileCollection'; interface LSAndTSDocResolverOptions { notifyExceedSizeLimit?: () => void; @@ -71,6 +73,40 @@ export class LSAndTSDocResolver { this.getCanonicalFileName = createGetCanonicalFileName( (options?.tsSystem ?? ts.sys).useCaseSensitiveFileNames ); + + this.tsSystem = this.wrapWithPackageJsonMonitoring(this.options?.tsSystem ?? ts.sys); + this.globalSnapshotsManager = new GlobalSnapshotsManager(this.tsSystem); + this.userPreferencesAccessor = { preferences: this.getTsUserPreferences() }; + const projectService = createProjectService(this.tsSystem, this.userPreferencesAccessor); + + configManager.onChange(() => { + const newPreferences = this.getTsUserPreferences(); + const autoImportConfigChanged = + newPreferences.includePackageJsonAutoImports !== + this.userPreferencesAccessor.preferences.includePackageJsonAutoImports; + + this.userPreferencesAccessor.preferences = newPreferences; + + if (autoImportConfigChanged) { + forAllServices((service) => { + service.onAutoImportProviderSettingsChanged(); + }); + } + }); + + this.watchers = new FileMap(this.tsSystem.useCaseSensitiveFileNames); + this.lsDocumentContext = { + ambientTypesSource: this.options?.isSvelteCheck ? 'svelte-check' : 'svelte2tsx', + createDocument: this.createDocument, + transformOnTemplateError: !this.options?.isSvelteCheck, + globalSnapshotsManager: this.globalSnapshotsManager, + notifyExceedSizeLimit: this.options?.notifyExceedSizeLimit, + extendedConfigCache: this.extendedConfigCache, + onProjectReloaded: this.options?.onProjectReloaded, + watchTsConfig: !!this.options?.watch, + tsSystem: this.tsSystem, + projectService: projectService + }; } /** @@ -89,26 +125,15 @@ export class LSAndTSDocResolver { return document; }; - private globalSnapshotsManager = new GlobalSnapshotsManager( - this.lsDocumentContext.tsSystem, - /* watchPackageJson */ !!this.options?.watch - ); + private tsSystem: ts.System; + private globalSnapshotsManager: GlobalSnapshotsManager; private extendedConfigCache = new Map(); private getCanonicalFileName: GetCanonicalFileName; - private get lsDocumentContext(): LanguageServiceDocumentContext { - return { - ambientTypesSource: this.options?.isSvelteCheck ? 'svelte-check' : 'svelte2tsx', - createDocument: this.createDocument, - transformOnTemplateError: !this.options?.isSvelteCheck, - globalSnapshotsManager: this.globalSnapshotsManager, - notifyExceedSizeLimit: this.options?.notifyExceedSizeLimit, - extendedConfigCache: this.extendedConfigCache, - onProjectReloaded: this.options?.onProjectReloaded, - watchTsConfig: !!this.options?.watch, - tsSystem: this.options?.tsSystem ?? ts.sys - }; - } + private userPreferencesAccessor: { preferences: ts.UserPreferences }; + private readonly watchers: FileMap; + + private lsDocumentContext: LanguageServiceDocumentContext; async getLSForPath(path: string) { return (await this.getTSService(path)).getService(); @@ -251,4 +276,73 @@ export class LSAndTSDocResolver { nearestWorkspaceUri ? urlToPath(nearestWorkspaceUri) : null ); } + + private getTsUserPreferences() { + return this.configManager.getTsUserPreferences('typescript', null); + } + + private wrapWithPackageJsonMonitoring(sys: ts.System): ts.System { + if (!sys.watchFile || !this.options?.watch) { + return sys; + } + + const watchFile = sys.watchFile; + return { + ...sys, + readFile: (path, encoding) => { + if (path.endsWith('package.json') && !this.watchers.has(path)) { + this.watchers.set( + path, + watchFile(path, this.onPackageJsonWatchChange.bind(this), 3_000) + ); + } + + return sys.readFile(path, encoding); + } + }; + } + + private onPackageJsonWatchChange(path: string, onWatchChange: ts.FileWatcherEventKind) { + const dir = dirname(path); + const projectService = this.lsDocumentContext.projectService; + const packageJsonCache = projectService?.packageJsonCache; + const normalizedPath = projectService?.toPath(path); + + if (onWatchChange === ts.FileWatcherEventKind.Deleted) { + this.watchers.get(path)?.close(); + this.watchers.delete(path); + packageJsonCache?.delete(normalizedPath); + } else { + packageJsonCache?.addOrUpdate(normalizedPath); + } + + forAllServices((service) => { + service.onPackageJsonChange(path); + }); + if (!path.includes('node_modules')) { + return; + } + + setTimeout(() => { + this.updateSnapshotsInDirectory(dir); + const realPath = + this.tsSystem.realpath && + this.getCanonicalFileName(normalizePath(this.tsSystem.realpath?.(dir))); + + // pnpm + if (realPath && realPath !== dir) { + this.updateSnapshotsInDirectory(realPath); + const realPkgPath = join(realPath, 'package.json'); + forAllServices((service) => { + service.onPackageJsonChange(realPkgPath); + }); + } + }, 500); + } + + private updateSnapshotsInDirectory(dir: string) { + this.globalSnapshotsManager.getByPrefix(dir).forEach((snapshot) => { + this.globalSnapshotsManager.updateTsOrJsFile(snapshot.filePath); + }); + } } diff --git a/packages/language-server/src/plugins/typescript/SnapshotManager.ts b/packages/language-server/src/plugins/typescript/SnapshotManager.ts index 8b035e65a..7ee160ffc 100644 --- a/packages/language-server/src/plugins/typescript/SnapshotManager.ts +++ b/packages/language-server/src/plugins/typescript/SnapshotManager.ts @@ -5,7 +5,6 @@ import { TextDocumentContentChangeEvent } from 'vscode-languageserver'; import { createGetCanonicalFileName, GetCanonicalFileName, normalizePath } from '../../utils'; import { EventEmitter } from 'events'; import { FileMap } from '../../lib/documents/fileCollection'; -import { dirname } from 'path'; type SnapshotChangeHandler = (fileName: string, newDocument: DocumentSnapshot | undefined) => void; @@ -18,20 +17,10 @@ export class GlobalSnapshotsManager { private emitter = new EventEmitter(); private documents: FileMap; private getCanonicalFileName: GetCanonicalFileName; - private packageJsonCache: PackageJsonCache; - constructor( - private readonly tsSystem: ts.System, - watchPackageJson = false - ) { + constructor(private readonly tsSystem: ts.System) { this.documents = new FileMap(tsSystem.useCaseSensitiveFileNames); this.getCanonicalFileName = createGetCanonicalFileName(tsSystem.useCaseSensitiveFileNames); - this.packageJsonCache = new PackageJsonCache( - tsSystem, - watchPackageJson, - this.getCanonicalFileName, - this.updateSnapshotsInDirectory.bind(this) - ); } get(fileName: string) { @@ -94,16 +83,6 @@ export class GlobalSnapshotsManager { removeChangeListener(listener: SnapshotChangeHandler) { this.emitter.off('change', listener); } - - getPackageJson(path: string) { - return this.packageJsonCache.getPackageJson(path); - } - - private updateSnapshotsInDirectory(dir: string) { - this.getByPrefix(dir).forEach((snapshot) => { - this.updateTsOrJsFile(snapshot.filePath); - }); - } } export interface TsFilesSpec { @@ -267,76 +246,3 @@ export class SnapshotManager { } export const ignoredBuildDirectories = ['__sapper__', '.svelte-kit']; - -class PackageJsonCache { - constructor( - private readonly tsSystem: ts.System, - private readonly watchPackageJson: boolean, - private readonly getCanonicalFileName: GetCanonicalFileName, - private readonly updateSnapshotsInDirectory: (directory: string) => void - ) { - this.watchers = new FileMap(tsSystem.useCaseSensitiveFileNames); - } - - private readonly watchers: FileMap; - - private packageJsonCache = new FileMap< - { text: string; modifiedTime: number | undefined } | undefined - >(); - - getPackageJson(path: string) { - if (!this.packageJsonCache.has(path)) { - this.packageJsonCache.set(path, this.initWatcherAndRead(path)); - } - - return this.packageJsonCache.get(path); - } - - private initWatcherAndRead(path: string) { - if (this.watchPackageJson) { - this.tsSystem.watchFile?.(path, this.onPackageJsonWatchChange.bind(this), 3_000); - } - const exist = this.tsSystem.fileExists(path); - - if (!exist) { - return undefined; - } - - return this.readPackageJson(path); - } - - private readPackageJson(path: string) { - return { - text: this.tsSystem.readFile(path) ?? '', - modifiedTime: this.tsSystem.getModifiedTime?.(path)?.valueOf() - }; - } - - private onPackageJsonWatchChange(path: string, onWatchChange: ts.FileWatcherEventKind) { - const dir = dirname(path); - - if (onWatchChange === ts.FileWatcherEventKind.Deleted) { - this.packageJsonCache.delete(path); - this.watchers.get(path)?.close(); - this.watchers.delete(path); - } else { - this.packageJsonCache.set(path, this.readPackageJson(path)); - } - - if (!path.includes('node_modules')) { - return; - } - - setTimeout(() => { - this.updateSnapshotsInDirectory(dir); - const realPath = - this.tsSystem.realpath && - this.getCanonicalFileName(normalizePath(this.tsSystem.realpath?.(dir))); - - // pnpm - if (realPath && realPath !== dir) { - this.updateSnapshotsInDirectory(realPath); - } - }, 500); - } -} diff --git a/packages/language-server/src/plugins/typescript/module-loader.ts b/packages/language-server/src/plugins/typescript/module-loader.ts index 007415a1b..4eefdc700 100644 --- a/packages/language-server/src/plugins/typescript/module-loader.ts +++ b/packages/language-server/src/plugins/typescript/module-loader.ts @@ -208,7 +208,8 @@ export function createSvelteModuleLoader( resolveModuleNames, resolveTypeReferenceDirectiveReferences, mightHaveInvalidatedResolutions, - clearPendingInvalidations + clearPendingInvalidations, + getModuleResolutionCache: () => tsModuleCache }; function resolveModuleNames( diff --git a/packages/language-server/src/plugins/typescript/service.ts b/packages/language-server/src/plugins/typescript/service.ts index 09cac7fba..5f2571e75 100644 --- a/packages/language-server/src/plugins/typescript/service.ts +++ b/packages/language-server/src/plugins/typescript/service.ts @@ -21,6 +21,7 @@ import { hasTsExtensions, isSvelteFilePath } from './utils'; +import { createProject, ProjectService } from './serviceCache'; export interface LanguageServiceContainer { readonly tsconfigPath: string; @@ -46,6 +47,8 @@ export interface LanguageServiceContainer { * Only works for TS versions that have ScriptKind.Deferred */ fileBelongsToProject(filePath: string, isNew: boolean): boolean; + onAutoImportProviderSettingsChanged(): void; + onPackageJsonChange(packageJsonPath: string): void; dispose(): void; } @@ -58,6 +61,8 @@ declare module 'typescript' { * that might change the module resolution results */ hasInvalidatedResolutions?: (sourceFile: string) => boolean; + + getModuleResolutionCache?(): ts.ModuleResolutionCache; } interface ResolvedModuleWithFailedLookupLocations { @@ -108,6 +113,7 @@ export interface LanguageServiceDocumentContext { onProjectReloaded: (() => void) | undefined; watchTsConfig: boolean; tsSystem: ts.System; + projectService: ProjectService | undefined; } export async function getService( @@ -214,28 +220,8 @@ async function createLanguageService( // Load all configs within the tsconfig scope and the one above so that they are all loaded // by the time they need to be accessed synchronously by DocumentSnapshots. await configLoader.loadConfigs(workspacePath); - const tsSystemWithPackageJsonCache = { - ...tsSystem, - /** - * While TypeScript doesn't cache package.json in the tsserver, they do cache the - * information they get from it within other internal APIs. We'll somewhat do the same - * by caching the text of the package.json file here. - */ - readFile: (path: string, encoding?: string | undefined) => { - if (basename(path) === 'package.json') { - return docContext.globalSnapshotsManager.getPackageJson(path)?.text; - } - return tsSystem.readFile(path, encoding); - } - }; - - const svelteModuleLoader = createSvelteModuleLoader( - getSnapshot, - compilerOptions, - tsSystemWithPackageJsonCache, - ts - ); + const svelteModuleLoader = createSvelteModuleLoader(getSnapshot, compilerOptions, tsSystem, ts); let svelteTsPath: string; try { @@ -262,6 +248,8 @@ async function createLanguageService( ? svelteHtmlDeclaration : './svelte-jsx-v4.d.ts'; + const changedFilesForExportCache = new Set(); + const svelteTsxFiles = ( isSvelte3 ? ['./svelte-shims.d.ts', './svelte-jsx.d.ts', './svelte-native-jsx.d.ts'] @@ -294,7 +282,8 @@ async function createLanguageService( getNewLine: () => tsSystem.newLine, resolveTypeReferenceDirectiveReferences: svelteModuleLoader.resolveTypeReferenceDirectiveReferences, - hasInvalidatedResolutions: svelteModuleLoader.mightHaveInvalidatedResolutions + hasInvalidatedResolutions: svelteModuleLoader.mightHaveInvalidatedResolutions, + getModuleResolutionCache: svelteModuleLoader.getModuleResolutionCache }; const documentRegistry = getOrCreateDocumentRegistry( @@ -302,7 +291,6 @@ async function createLanguageService( tsSystem.useCaseSensitiveFileNames ); - const languageService = ts.createLanguageService(host, documentRegistry); const transformationConfig: SvelteSnapshotOptions = { parse: svelteCompiler?.parse, version: svelteCompiler?.VERSION, @@ -310,6 +298,9 @@ async function createLanguageService( typingsNamespace: raw?.svelteOptions?.namespace || 'svelteHTML' }; + const project = initLsCacheProject(); + const languageService = ts.createLanguageService(host, documentRegistry); + docContext.globalSnapshotsManager.onChange(scheduleUpdate); reduceLanguageServiceCapabilityIfFileSizeTooBig(); @@ -329,6 +320,8 @@ async function createLanguageService( fileBelongsToProject, snapshotManager, invalidateModuleCache, + onAutoImportProviderSettingsChanged, + onPackageJsonChange, dispose }; @@ -350,7 +343,7 @@ async function createLanguageService( svelteModuleLoader.deleteFromModuleCache(filePath); svelteModuleLoader.deleteUnresolvedResolutionsFromCache(filePath); - scheduleUpdate(); + scheduleUpdate(filePath); } function updateSnapshot(documentOrFilePath: Document | string): DocumentSnapshot { @@ -383,15 +376,7 @@ async function createLanguageService( return prevSnapshot; } - svelteModuleLoader.deleteUnresolvedResolutionsFromCache(filePath); - const newSnapshot = DocumentSnapshot.fromFilePath( - filePath, - docContext.createDocument, - transformationConfig, - tsSystem - ); - snapshotManager.set(filePath, newSnapshot); - return newSnapshot; + return createSnapshot(filePath); } /** @@ -412,8 +397,7 @@ async function createLanguageService( } return createSnapshot( - svelteModuleLoader.svelteFileExists(fileName) ? svelteFileName : fileName, - doc + svelteModuleLoader.svelteFileExists(fileName) ? svelteFileName : fileName ); } @@ -425,12 +409,12 @@ async function createLanguageService( return doc; } - return createSnapshot(fileName, doc); + return createSnapshot(fileName); } - function createSnapshot(fileName: string, doc: DocumentSnapshot | undefined) { + function createSnapshot(fileName: string) { svelteModuleLoader.deleteUnresolvedResolutionsFromCache(fileName); - doc = DocumentSnapshot.fromFilePath( + const doc = DocumentSnapshot.fromFilePath( fileName, docContext.createDocument, transformationConfig, @@ -441,8 +425,7 @@ async function createLanguageService( } function updateProjectFiles(): void { - projectVersion++; - dirty = true; + scheduleUpdate(); const projectFileCountBefore = snapshotManager.getProjectFileNames().length; snapshotManager.updateProjectFiles(); const projectFileCountAfter = snapshotManager.getProjectFileNames().length; @@ -641,6 +624,9 @@ async function createLanguageService( ) { languageService.cleanupSemanticCache(); languageServiceReducedMode = true; + if (project) { + project.languageServiceEnabled = false; + } docContext.notifyExceedSizeLimit?.(); } } @@ -723,13 +709,44 @@ async function createLanguageService( return; } - languageService.getProgram(); + const oldProgram = project?.program; + const program = languageService.getProgram(); svelteModuleLoader.clearPendingInvalidations(); + if (project) { + project.program = program; + } + dirty = false; + + if (!oldProgram) { + changedFilesForExportCache.clear(); + return; + } + + for (const fileName of changedFilesForExportCache) { + const oldFile = oldProgram.getSourceFile(fileName); + const newFile = program?.getSourceFile(fileName); + + // file for another tsconfig + if (!oldFile && !newFile) { + continue; + } + + if (oldFile && newFile) { + host.getCachedExportInfoMap?.().onFileChanged?.(oldFile, newFile, false); + } else { + // new file or deleted file + host.getCachedExportInfoMap?.().clear(); + } + } + changedFilesForExportCache.clear(); } - function scheduleUpdate() { + function scheduleUpdate(triggeredFile?: string) { + if (triggeredFile) { + changedFilesForExportCache.add(triggeredFile); + } if (dirty) { return; } @@ -737,6 +754,53 @@ async function createLanguageService( projectVersion++; dirty = true; } + + function initLsCacheProject() { + const projectService = docContext.projectService; + if (!projectService) { + return; + } + + // Used by typescript-auto-import-cache to create a lean language service for package.json auto-import. + const createLanguageServiceForAutoImportProvider = (host: ts.LanguageServiceHost) => + ts.createLanguageService(host, documentRegistry); + + return createProject(host, createLanguageServiceForAutoImportProvider, { + compilerOptions: compilerOptions, + projectService: projectService, + currentDirectory: workspacePath + }); + } + + function onAutoImportProviderSettingsChanged() { + project?.onAutoImportProviderSettingsChanged(); + } + + function onPackageJsonChange(packageJsonPath: string) { + if (!project) { + return; + } + + if (project.packageJsonsForAutoImport?.has(packageJsonPath)) { + project.moduleSpecifierCache.clear(); + + if (project.autoImportProviderHost) { + project.autoImportProviderHost.markAsDirty(); + } + } + + if (packageJsonPath.includes('node_modules')) { + const dir = dirname(packageJsonPath); + const inProgram = project + .getCurrentProgram() + ?.getSourceFiles() + .some((file) => file.fileName.includes(dir)); + + if (inProgram) { + host.getModuleSpecifierCache?.().clear(); + } + } + } } /** diff --git a/packages/language-server/src/plugins/typescript/serviceCache.ts b/packages/language-server/src/plugins/typescript/serviceCache.ts new file mode 100644 index 000000000..0dbb3bfe2 --- /dev/null +++ b/packages/language-server/src/plugins/typescript/serviceCache.ts @@ -0,0 +1,93 @@ +// abstracting the typescript-auto-import-cache package to support our use case + +import { + ProjectService, + createProjectService as createProjectService50 +} from 'typescript-auto-import-cache/out/5_0/projectService'; +import { createProject as createProject50 } from 'typescript-auto-import-cache/out/5_0/project'; +import { createProject as createProject53 } from 'typescript-auto-import-cache/out/5_3/project'; +import ts from 'typescript'; +import { ExportInfoMap } from 'typescript-auto-import-cache/out/5_0/exportInfoMap'; +import { ModuleSpecifierCache } from 'typescript-auto-import-cache/out/5_0/moduleSpecifierCache'; +import { SymlinkCache } from 'typescript-auto-import-cache/out/5_0/symlinkCache'; +import { ProjectPackageJsonInfo } from 'typescript-auto-import-cache/out/5_0/packageJsonCache'; + +export { ProjectService }; + +declare module 'typescript' { + interface LanguageServiceHost { + /** @internal */ getCachedExportInfoMap?(): ExportInfoMap; + /** @internal */ getModuleSpecifierCache?(): ModuleSpecifierCache; + /** @internal */ getGlobalTypingsCacheLocation?(): string | undefined; + /** @internal */ getSymlinkCache?(files: readonly ts.SourceFile[]): SymlinkCache; + /** @internal */ getPackageJsonsVisibleToFile?( + fileName: string, + rootDir?: string + ): readonly ProjectPackageJsonInfo[]; + /** @internal */ getPackageJsonAutoImportProvider?(): ts.Program | undefined; + /** @internal */ useSourceOfProjectReferenceRedirect?(): boolean; + } +} + +export function createProjectService( + system: ts.System, + hostConfiguration: { + preferences: ts.UserPreferences; + } +) { + const version = ts.version.split('.'); + const major = parseInt(version[0]); + + if (major < 5) { + return undefined; + } + + const projectService = createProjectService50( + ts, + system, + system.getCurrentDirectory(), + hostConfiguration, + ts.LanguageServiceMode.Semantic + ); + + return projectService; +} + +export function createProject( + host: ts.LanguageServiceHost, + createLanguageService: (host: ts.LanguageServiceHost) => ts.LanguageService, + options: { + projectService: ProjectService; + compilerOptions: ts.CompilerOptions; + currentDirectory: string; + } +) { + const version = ts.version.split('.'); + const major = parseInt(version[0]); + const minor = parseInt(version[1]); + + if (major < 5) { + return undefined; + } + + const factory = minor < 3 ? createProject50 : createProject53; + const project = factory(ts, host, createLanguageService, options); + + const proxyMethods: (keyof typeof project)[] = [ + 'getCachedExportInfoMap', + 'getModuleSpecifierCache', + 'getGlobalTypingsCacheLocation', + 'getSymlinkCache', + 'getPackageJsonsVisibleToFile', + 'getPackageJsonAutoImportProvider', + 'includePackageJsonAutoImports', + 'useSourceOfProjectReferenceRedirect' + ]; + proxyMethods.forEach((key) => ((host as any)[key] = project[key].bind(project))); + + if (host.log) { + project.log = host.log.bind(host); + } + + return project; +} diff --git a/packages/language-server/test/plugins/typescript/features/CompletionProvider.test.ts b/packages/language-server/test/plugins/typescript/features/CompletionProvider.test.ts index 132539a1d..c2dc0fcd8 100644 --- a/packages/language-server/test/plugins/typescript/features/CompletionProvider.test.ts +++ b/packages/language-server/test/plugins/typescript/features/CompletionProvider.test.ts @@ -1378,6 +1378,125 @@ describe('CompletionProviderImpl', function () { assert.strictEqual(detail, 'Add import from "random-package2"\n\nfunction foo(): string'); }); + it('can auto import package not in the program', async () => { + const virtualTestDir = getRandomVirtualDirPath(testFilesDir); + const { document, lsAndTsDocResolver, lsConfigManager, virtualSystem } = + setupVirtualEnvironment({ + filename: 'index.svelte', + fileContent: '', + testDir: virtualTestDir + }); + + const mockPackageDir = join(virtualTestDir, 'node_modules', 'random-package'); + + virtualSystem.writeFile( + join(virtualTestDir, 'package.json'), + JSON.stringify({ + dependencies: { + 'random-package': '*' + } + }) + ); + + virtualSystem.writeFile( + join(mockPackageDir, 'package.json'), + JSON.stringify({ + name: 'random-package', + version: '1.0.0' + }) + ); + + virtualSystem.writeFile( + join(mockPackageDir, 'index.d.ts'), + 'export function bar(): string' + ); + + const completionProvider = new CompletionsProviderImpl(lsAndTsDocResolver, lsConfigManager); + + const completions = await completionProvider.getCompletions(document, { + line: 0, + character: 9 + }); + const item = completions?.items.find((item) => item.label === 'bar'); + + const { detail } = await completionProvider.resolveCompletion(document, item!); + + assert.strictEqual(detail, 'Add import from "random-package"\n\nfunction bar(): string'); + }); + + it('can auto import new file', async () => { + const virtualTestDir = getRandomVirtualDirPath(testFilesDir); + const { document, lsAndTsDocResolver, lsConfigManager, docManager } = + setupVirtualEnvironment({ + filename: 'index.svelte', + fileContent: '', + testDir: virtualTestDir + }); + + const completionProvider = new CompletionsProviderImpl(lsAndTsDocResolver, lsConfigManager); + + const completions = await completionProvider.getCompletions(document, { + line: 0, + character: 9 + }); + + const item = completions?.items.find((item) => item.label === 'Bar'); + + assert.equal(item, undefined); + + docManager.openClientDocument({ + text: '', + uri: pathToUrl(join(virtualTestDir, 'Bar.svelte')) + }); + + const completions2 = await completionProvider.getCompletions(document, { + line: 0, + character: 9 + }); + + const item2 = completions2?.items.find((item) => item.label === 'Bar'); + const { detail } = await completionProvider.resolveCompletion(document, item2!); + + assert.strictEqual(detail, 'Add import from "./Bar.svelte"\n\nclass Bar'); + }); + + it('can auto import new export', async () => { + const virtualTestDir = getRandomVirtualDirPath(testFilesDir); + const { document, lsAndTsDocResolver, lsConfigManager, virtualSystem } = + setupVirtualEnvironment({ + filename: 'index.svelte', + fileContent: '', + testDir: virtualTestDir + }); + + const completionProvider = new CompletionsProviderImpl(lsAndTsDocResolver, lsConfigManager); + const tsFile = join(virtualTestDir, 'foo.ts'); + + virtualSystem.writeFile(tsFile, 'export {}'); + + const completions = await completionProvider.getCompletions(document, { + line: 0, + character: 31 + }); + + const item = completions?.items.find((item) => item.label === 'foo'); + + assert.equal(item, undefined); + + virtualSystem.writeFile(tsFile, 'export function foo() {}'); + lsAndTsDocResolver.updateExistingTsOrJsFile(tsFile); + + const completions2 = await completionProvider.getCompletions(document, { + line: 0, + character: 31 + }); + + const item2 = completions2?.items.find((item) => item.label === 'foo'); + const { detail } = await completionProvider.resolveCompletion(document, item2!); + + assert.strictEqual(detail, 'Update import from "./foo"\n\nfunction foo(): void'); + }); + it('provides completions for object literal member', async () => { const { completionProvider, document } = setup('object-member.svelte'); @@ -1463,7 +1582,7 @@ describe('CompletionProviderImpl', function () { const item = completions?.items.find((item) => item.label === '$store'); assert.ok(item); - assert.equal(item?.data?.source?.endsWith('completions/to-import'), true); + assert.equal(item?.data?.source?.endsWith('/to-import'), true); const { data, ...itemWithoutData } = item; @@ -1476,7 +1595,9 @@ describe('CompletionProviderImpl', function () { insertTextFormat: undefined, commitCharacters: ['.', ',', ';', '('], textEdit: undefined, - labelDetails: undefined + labelDetails: { + description: './to-import' + } }); const { detail } = await completionProvider.resolveCompletion(document, item); diff --git a/packages/language-server/test/plugins/typescript/service.test.ts b/packages/language-server/test/plugins/typescript/service.test.ts index 1b2801bc1..f8b36914a 100644 --- a/packages/language-server/test/plugins/typescript/service.test.ts +++ b/packages/language-server/test/plugins/typescript/service.test.ts @@ -27,7 +27,8 @@ describe('service', () => { tsSystem: virtualSystem, watchTsConfig: false, notifyExceedSizeLimit: undefined, - onProjectReloaded: undefined + onProjectReloaded: undefined, + projectService: undefined }; const rootUris = [pathToUrl(testDir)]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1fb51a95..828565e96 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,6 +60,9 @@ importers: typescript: specifier: ^5.3.2 version: 5.3.2 + typescript-auto-import-cache: + specifier: ^0.3.2 + version: 0.3.2 vscode-css-languageservice: specifier: ~6.2.10 version: 6.2.10 @@ -1924,6 +1927,12 @@ packages: engines: {node: '>=4'} dev: true + /typescript-auto-import-cache@0.3.2: + resolution: {integrity: sha512-+laqe5SFL1vN62FPOOJSUDTZxtgsoOXjneYOXIpx5rQ4UMiN89NAtJLpqLqyebv9fgQ/IMeeTX+mQyRnwvJzvg==} + dependencies: + semver: 7.5.1 + dev: false + /typescript@5.3.2: resolution: {integrity: sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==} engines: {node: '>=14.17'}