diff --git a/packages/language-server/src/plugins/typescript/LSAndTSDocResolver.ts b/packages/language-server/src/plugins/typescript/LSAndTSDocResolver.ts index 3843e8a89..3b0a76c06 100644 --- a/packages/language-server/src/plugins/typescript/LSAndTSDocResolver.ts +++ b/packages/language-server/src/plugins/typescript/LSAndTSDocResolver.ts @@ -1,6 +1,10 @@ import { dirname, join } from 'path'; import ts from 'typescript'; -import { RelativePattern, TextDocumentContentChangeEvent } from 'vscode-languageserver'; +import { + PublishDiagnosticsParams, + RelativePattern, + TextDocumentContentChangeEvent +} from 'vscode-languageserver'; import { Document, DocumentManager } from '../../lib/documents'; import { LSConfigManager } from '../../ls-config'; import { @@ -37,6 +41,7 @@ interface LSAndTSDocResolverOptions { tsconfigPath?: string; onProjectReloaded?: () => void; + reportConfigError?: (diagnostic: PublishDiagnosticsParams) => void; watch?: boolean; tsSystem?: ts.System; watchDirectory?: (patterns: RelativePattern[]) => void; @@ -50,14 +55,10 @@ export class LSAndTSDocResolver { private readonly configManager: LSConfigManager, private readonly options?: LSAndTSDocResolverOptions ) { - const handleDocumentChange = (document: Document) => { - // This refreshes the document in the ts language service - this.getSnapshot(document); - }; docManager.on( 'documentChange', debounceSameArg( - handleDocumentChange, + this.updateSnapshot.bind(this), (newDoc, prevDoc) => newDoc.uri === prevDoc?.uri, 1000 ) @@ -68,7 +69,11 @@ export class LSAndTSDocResolver { // where multiple files and their dependencies // being loaded in a short period of times docManager.on('documentOpen', (document) => { - handleDocumentChange(document); + if (document.openedByClient) { + this.getOrCreateSnapshot(document); + } else { + this.updateSnapshot(document); + } docManager.lockDocument(document.uri); }); @@ -121,7 +126,8 @@ export class LSAndTSDocResolver { watchDirectory: this.options?.watchDirectory ? this.watchDirectory.bind(this) : undefined, - nonRecursiveWatchPattern: this.options?.nonRecursiveWatchPattern + nonRecursiveWatchPattern: this.options?.nonRecursiveWatchPattern, + reportConfigError: this.options?.reportConfigError }; } @@ -151,18 +157,20 @@ export class LSAndTSDocResolver { private lsDocumentContext: LanguageServiceDocumentContext; private readonly watchedDirectories: FileSet; - async getLSForPath(path: string) { - return (await this.getTSService(path)).getService(); - } - async getLSAndTSDoc(document: Document): Promise<{ tsDoc: SvelteDocumentSnapshot; lang: ts.LanguageService; userPreferences: ts.UserPreferences; + lsContainer: LanguageServiceContainer; }> { const { tsDoc, lsContainer, userPreferences } = await this.getLSAndTSDocWorker(document); - return { tsDoc, lang: lsContainer.getService(), userPreferences }; + return { + tsDoc, + lang: lsContainer.getService(), + userPreferences, + lsContainer + }; } /** @@ -181,7 +189,7 @@ export class LSAndTSDocResolver { private async getLSAndTSDocWorker(document: Document) { const lsContainer = await this.getTSService(document.getFilePath() || ''); - const tsDoc = await this.getSnapshot(document); + const tsDoc = await this.getOrCreateSnapshot(document); const userPreferences = this.getUserPreferences(tsDoc); return { tsDoc, lsContainer, userPreferences }; @@ -192,13 +200,21 @@ export class LSAndTSDocResolver { * the ts service it primarily belongs into. * The update is mirrored in all other services, too. */ - async getSnapshot(document: Document): Promise; - async getSnapshot(pathOrDoc: string | Document): Promise; - async getSnapshot(pathOrDoc: string | Document) { + async getOrCreateSnapshot(document: Document): Promise; + async getOrCreateSnapshot(pathOrDoc: string | Document): Promise; + async getOrCreateSnapshot(pathOrDoc: string | Document) { const filePath = typeof pathOrDoc === 'string' ? pathOrDoc : pathOrDoc.getFilePath() || ''; const tsService = await this.getTSService(filePath); return tsService.updateSnapshot(pathOrDoc); } + private async updateSnapshot(document: Document) { + const filePath = document.getFilePath(); + if (!filePath) { + return; + } + // ensure no new service is created + await this.updateExistingFile(filePath, (service) => service.updateSnapshot(document)); + } /** * Updates snapshot path in all existing ts services and retrieves snapshot @@ -217,7 +233,7 @@ export class LSAndTSDocResolver { }); } else { // This may not be a file but a directory, still try - await this.getSnapshot(newPath); + await this.getOrCreateSnapshot(newPath); } } @@ -280,19 +296,11 @@ export class LSAndTSDocResolver { }); } - /** - * @internal Public for tests only - */ - async getSnapshotManager(filePath: string): Promise { - return (await this.getTSService(filePath)).snapshotManager; - } - async getTSService(filePath?: string): Promise { if (this.options?.tsconfigPath) { - return getServiceForTsconfig( - this.options?.tsconfigPath, - dirname(this.options.tsconfigPath), - this.lsDocumentContext + return this.getTSServiceByConfigPath( + this.options.tsconfigPath, + dirname(this.options.tsconfigPath) ); } if (!filePath) { @@ -301,6 +309,13 @@ export class LSAndTSDocResolver { return getService(filePath, this.workspaceUris, this.lsDocumentContext); } + async getTSServiceByConfigPath( + tsconfigPath: string, + workspacePath: string + ): Promise { + return getServiceForTsconfig(tsconfigPath, workspacePath, this.lsDocumentContext); + } + private getUserPreferences(tsDoc: DocumentSnapshot): ts.UserPreferences { const configLang = tsDoc.scriptKind === ts.ScriptKind.TS || tsDoc.scriptKind === ts.ScriptKind.TSX diff --git a/packages/language-server/src/plugins/typescript/SnapshotManager.ts b/packages/language-server/src/plugins/typescript/SnapshotManager.ts index 6bf64f0b6..85b152e49 100644 --- a/packages/language-server/src/plugins/typescript/SnapshotManager.ts +++ b/packages/language-server/src/plugins/typescript/SnapshotManager.ts @@ -262,6 +262,11 @@ export class SnapshotManager { return Array.from(this.projectFileToOriginalCasing.values()); } + isProjectFile(fileName: string): boolean { + fileName = normalizePath(fileName); + return this.projectFileToOriginalCasing.has(this.getCanonicalFileName(fileName)); + } + private logStatistics() { const date = new Date(); // Don't use setInterval because that will keep tests running forever diff --git a/packages/language-server/src/plugins/typescript/TypeScriptPlugin.ts b/packages/language-server/src/plugins/typescript/TypeScriptPlugin.ts index bc97f6175..264fe6665 100644 --- a/packages/language-server/src/plugins/typescript/TypeScriptPlugin.ts +++ b/packages/language-server/src/plugins/typescript/TypeScriptPlugin.ts @@ -168,7 +168,10 @@ export class TypeScriptPlugin this.completionProvider, configManager ); - this.updateImportsProvider = new UpdateImportsProviderImpl(this.lsAndTsDocResolver); + this.updateImportsProvider = new UpdateImportsProviderImpl( + this.lsAndTsDocResolver, + ts.sys.useCaseSensitiveFileNames + ); this.diagnosticsProvider = new DiagnosticsProviderImpl( this.lsAndTsDocResolver, configManager @@ -383,7 +386,7 @@ export class TypeScriptPlugin } async getDefinitions(document: Document, position: Position): Promise { - const { lang, tsDoc } = await this.lsAndTsDocResolver.getLSAndTSDoc(document); + const { lang, tsDoc, lsContainer } = await this.lsAndTsDocResolver.getLSAndTSDoc(document); const defs = lang.getDefinitionAndBoundSpan( tsDoc.filePath, @@ -394,7 +397,7 @@ export class TypeScriptPlugin return []; } - const snapshots = new SnapshotMap(this.lsAndTsDocResolver); + const snapshots = new SnapshotMap(this.lsAndTsDocResolver, lsContainer); snapshots.set(tsDoc.filePath, tsDoc); const result = await Promise.all( diff --git a/packages/language-server/src/plugins/typescript/features/CallHierarchyProvider.ts b/packages/language-server/src/plugins/typescript/features/CallHierarchyProvider.ts index b58e797a2..3d60b40ac 100644 --- a/packages/language-server/src/plugins/typescript/features/CallHierarchyProvider.ts +++ b/packages/language-server/src/plugins/typescript/features/CallHierarchyProvider.ts @@ -41,7 +41,7 @@ export class CallHierarchyProviderImpl implements CallHierarchyProvider { position: Position, cancellationToken?: CancellationToken ): Promise { - const { lang, tsDoc } = await this.lsAndTsDocResolver.getLSAndTSDoc(document); + const { lang, tsDoc, lsContainer } = await this.lsAndTsDocResolver.getLSAndTSDoc(document); if (cancellationToken?.isCancellationRequested) { return null; @@ -52,7 +52,7 @@ export class CallHierarchyProviderImpl implements CallHierarchyProvider { const itemsArray = Array.isArray(items) ? items : items ? [items] : []; - const snapshots = new SnapshotMap(this.lsAndTsDocResolver); + const snapshots = new SnapshotMap(this.lsAndTsDocResolver, lsContainer); snapshots.set(tsDoc.filePath, tsDoc); const program = lang.getProgram(); @@ -251,8 +251,9 @@ export class CallHierarchyProviderImpl implements CallHierarchyProvider { return null; } - const lang = await this.lsAndTsDocResolver.getLSForPath(filePath); - const tsDoc = await this.lsAndTsDocResolver.getSnapshot(filePath); + const lsContainer = await this.lsAndTsDocResolver.getTSService(filePath); + const lang = lsContainer.getService(); + const tsDoc = await this.lsAndTsDocResolver.getOrCreateSnapshot(filePath); if (cancellationToken?.isCancellationRequested) { return null; @@ -260,7 +261,7 @@ export class CallHierarchyProviderImpl implements CallHierarchyProvider { const program = lang.getProgram(); - const snapshots = new SnapshotMap(this.lsAndTsDocResolver); + const snapshots = new SnapshotMap(this.lsAndTsDocResolver, lsContainer); snapshots.set(tsDoc.filePath, tsDoc); const isComponentModulePosition = diff --git a/packages/language-server/src/plugins/typescript/features/CodeActionsProvider.ts b/packages/language-server/src/plugins/typescript/features/CodeActionsProvider.ts index 287f21664..59def877c 100644 --- a/packages/language-server/src/plugins/typescript/features/CodeActionsProvider.ts +++ b/packages/language-server/src/plugins/typescript/features/CodeActionsProvider.ts @@ -46,7 +46,6 @@ import { import { CompletionsProviderImpl } from './CompletionProvider'; import { findClosestContainingNode, - findContainingNode, FormatCodeBasis, getFormatCodeBasis, getNewScriptStartTag, @@ -56,6 +55,7 @@ import { } from './utils'; import { DiagnosticCode } from './DiagnosticsProvider'; import { createGetCanonicalFileName } from '../../../utils'; +import { LanguageServiceContainer } from '../service'; /** * TODO change this to protocol constant if it's part of the protocol @@ -156,7 +156,7 @@ export class CodeActionsProviderImpl implements CodeActionsProvider { return codeAction; } - const { lang, tsDoc, userPreferences } = + const { lang, tsDoc, userPreferences, lsContainer } = await this.lsAndTsDocResolver.getLSAndTSDoc(document); if (cancellationToken?.isCancellationRequested) { return codeAction; @@ -180,10 +180,11 @@ export class CodeActionsProviderImpl implements CodeActionsProvider { const isImportFix = codeAction.data.fixName === FIX_IMPORT_FIX_NAME; const virtualDocInfo = isImportFix - ? await this.createVirtualDocumentForCombinedImportCodeFix( + ? this.createVirtualDocumentForCombinedImportCodeFix( document, getDiagnostics(), tsDoc, + lsContainer, lang ) : undefined; @@ -218,7 +219,7 @@ export class CodeActionsProviderImpl implements CodeActionsProvider { await this.lsAndTsDocResolver.deleteSnapshot(virtualDocPath); } - const snapshots = new SnapshotMap(this.lsAndTsDocResolver); + const snapshots = new SnapshotMap(this.lsAndTsDocResolver, lsContainer); const fixActions: ts.CodeFixAction[] = [ { fixName: codeAction.data.fixName, @@ -259,10 +260,11 @@ export class CodeActionsProviderImpl implements CodeActionsProvider { * Do not use this in regular code action * This'll cause TypeScript to rebuild and invalidate caches every time. It'll be slow */ - private async createVirtualDocumentForCombinedImportCodeFix( + private createVirtualDocumentForCombinedImportCodeFix( document: Document, diagnostics: Diagnostic[], tsDoc: DocumentSnapshot, + lsContainer: LanguageServiceContainer, lang: ts.LanguageService ) { const virtualUri = document.uri + '.__virtual__.svelte'; @@ -314,10 +316,8 @@ export class CodeActionsProviderImpl implements CodeActionsProvider { const virtualDoc = new Document(virtualUri, newText); virtualDoc.openedByClient = true; // let typescript know about the virtual document - // getLSAndTSDoc instead of getSnapshot so that project dirty state is correctly tracked by us - // otherwise, sometime the applied code fix might not be picked up by the language service - // because we think the project is still dirty and doesn't update the project version - await this.lsAndTsDocResolver.getLSAndTSDoc(virtualDoc); + lsContainer.openVirtualDocument(virtualDoc); + lsContainer.getService(); return { virtualDoc, @@ -553,7 +553,7 @@ export class CodeActionsProviderImpl implements CodeActionsProvider { context: CodeActionContext, cancellationToken: CancellationToken | undefined ) { - const { lang, tsDoc, userPreferences } = await this.getLSAndTSDoc(document); + const { lang, tsDoc, userPreferences, lsContainer } = await this.getLSAndTSDoc(document); if (cancellationToken?.isCancellationRequested) { return []; @@ -613,7 +613,7 @@ export class CodeActionsProviderImpl implements CodeActionsProvider { ); } - const snapshots = new SnapshotMap(this.lsAndTsDocResolver); + const snapshots = new SnapshotMap(this.lsAndTsDocResolver, lsContainer); snapshots.set(tsDoc.filePath, tsDoc); const codeActionsPromises = codeFixes.map(async (fix) => { diff --git a/packages/language-server/src/plugins/typescript/features/CompletionProvider.ts b/packages/language-server/src/plugins/typescript/features/CompletionProvider.ts index ffb044938..e7b5bec75 100644 --- a/packages/language-server/src/plugins/typescript/features/CompletionProvider.ts +++ b/packages/language-server/src/plugins/typescript/features/CompletionProvider.ts @@ -49,6 +49,7 @@ import { isPartOfImportStatement } from './utils'; import { isInTag as svelteIsInTag } from '../svelte-ast-utils'; +import { LanguageServiceContainer } from '../service'; export interface CompletionResolveInfo extends Pick, @@ -170,7 +171,7 @@ export class CompletionsProviderImpl implements CompletionsProvider 0 ? [] - : await this.getCustomElementCompletions(lang, document, tsDoc, position); + : this.getCustomElementCompletions(lang, lsContainer, document, tsDoc, position); const formatSettings = await this.configManager.getFormatCodeSettingsForFile( document, @@ -474,12 +475,13 @@ export class CompletionsProviderImpl implements CompletionsProvider { + ): CompletionItem[] | undefined { const offset = document.offsetAt(position); const tag = getNodeIfIsInHTMLStartTag(document.html, offset); @@ -499,9 +501,7 @@ export class CompletionsProviderImpl implements CompletionsProvider ref.definition.kind === ts.ScriptElementKind.alias)) { @@ -124,7 +124,7 @@ export class FindReferencesProviderImpl implements FindReferencesProvider { let storeReferences: ts.ReferencedSymbolEntry[] = []; const storeReference = references.find( (ref) => - ref.fileName === tsDoc.filePath && + normalizePath(ref.fileName) === tsDoc.filePath && isTextSpanInGeneratedCode(tsDoc.getFullText(), ref.textSpan) && is$storeVariableIn$storeDeclaration(tsDoc.getFullText(), ref.textSpan.start) ); diff --git a/packages/language-server/src/plugins/typescript/features/ImplementationProvider.ts b/packages/language-server/src/plugins/typescript/features/ImplementationProvider.ts index e4a91c1bb..693afaa49 100644 --- a/packages/language-server/src/plugins/typescript/features/ImplementationProvider.ts +++ b/packages/language-server/src/plugins/typescript/features/ImplementationProvider.ts @@ -18,7 +18,7 @@ export class ImplementationProviderImpl implements ImplementationProvider { position: Position, cancellationToken?: CancellationToken ): Promise { - const { tsDoc, lang } = await this.lsAndTsDocResolver.getLSAndTSDoc(document); + const { tsDoc, lang, lsContainer } = await this.lsAndTsDocResolver.getLSAndTSDoc(document); if (cancellationToken?.isCancellationRequested) { return null; @@ -27,7 +27,7 @@ export class ImplementationProviderImpl implements ImplementationProvider { const offset = tsDoc.offsetAt(tsDoc.getGeneratedPosition(position)); const implementations = lang.getImplementationAtPosition(tsDoc.filePath, offset); - const snapshots = new SnapshotMap(this.lsAndTsDocResolver); + const snapshots = new SnapshotMap(this.lsAndTsDocResolver, lsContainer); snapshots.set(tsDoc.filePath, tsDoc); if (!implementations) { diff --git a/packages/language-server/src/plugins/typescript/features/InlayHintProvider.ts b/packages/language-server/src/plugins/typescript/features/InlayHintProvider.ts index 9ca919900..a11fb0f43 100644 --- a/packages/language-server/src/plugins/typescript/features/InlayHintProvider.ts +++ b/packages/language-server/src/plugins/typescript/features/InlayHintProvider.ts @@ -41,7 +41,7 @@ export class InlayHintProviderImpl implements InlayHintProvider { return null; } - const { tsDoc, lang } = await this.lsAndTsDocResolver.getLSAndTSDoc(document); + const { tsDoc, lang, lsContainer } = await this.lsAndTsDocResolver.getLSAndTSDoc(document); const inlayHints = lang.provideInlayHints( tsDoc.filePath, @@ -59,7 +59,7 @@ export class InlayHintProviderImpl implements InlayHintProvider { const renderFunctionReturnTypeLocation = renderFunction && this.getTypeAnnotationPosition(renderFunction); - const snapshotMap = new SnapshotMap(this.lsAndTsDocResolver); + const snapshotMap = new SnapshotMap(this.lsAndTsDocResolver, lsContainer); snapshotMap.set(tsDoc.filePath, tsDoc); const convertPromises = inlayHints diff --git a/packages/language-server/src/plugins/typescript/features/RenameProvider.ts b/packages/language-server/src/plugins/typescript/features/RenameProvider.ts index 281ae659f..3b9caf86e 100644 --- a/packages/language-server/src/plugins/typescript/features/RenameProvider.ts +++ b/packages/language-server/src/plugins/typescript/features/RenameProvider.ts @@ -65,7 +65,7 @@ export class RenameProviderImpl implements RenameProvider { position: Position, newName: string ): Promise { - const { lang, tsDoc } = await this.getLSAndTSDoc(document); + const { lang, tsDoc, lsContainer } = await this.getLSAndTSDoc(document); const offset = tsDoc.offsetAt(tsDoc.getGeneratedPosition(position)); @@ -85,7 +85,7 @@ export class RenameProviderImpl implements RenameProvider { return null; } - const docs = new SnapshotMap(this.lsAndTsDocResolver); + const docs = new SnapshotMap(this.lsAndTsDocResolver, lsContainer); docs.set(tsDoc.filePath, tsDoc); let convertedRenameLocations: TsRenameLocation[] = await this.mapAndFilterRenameLocations( @@ -536,7 +536,7 @@ export class RenameProviderImpl implements RenameProvider { } private getSnapshot(filePath: string) { - return this.lsAndTsDocResolver.getSnapshot(filePath); + return this.lsAndTsDocResolver.getOrCreateSnapshot(filePath); } private checkShortHandBindingOrSlotLetLocation( diff --git a/packages/language-server/src/plugins/typescript/features/TypeDefinitionProvider.ts b/packages/language-server/src/plugins/typescript/features/TypeDefinitionProvider.ts index 93674fd9e..3c2ecf625 100644 --- a/packages/language-server/src/plugins/typescript/features/TypeDefinitionProvider.ts +++ b/packages/language-server/src/plugins/typescript/features/TypeDefinitionProvider.ts @@ -10,11 +10,11 @@ export class TypeDefinitionProviderImpl implements TypeDefinitionProvider { constructor(private readonly lsAndTsDocResolver: LSAndTSDocResolver) {} async getTypeDefinition(document: Document, position: Position): Promise { - const { tsDoc, lang } = await this.lsAndTsDocResolver.getLSAndTSDoc(document); + const { tsDoc, lang, lsContainer } = await this.lsAndTsDocResolver.getLSAndTSDoc(document); const offset = tsDoc.offsetAt(tsDoc.getGeneratedPosition(position)); const typeDefs = lang.getTypeDefinitionAtPosition(tsDoc.filePath, offset); - const snapshots = new SnapshotMap(this.lsAndTsDocResolver); + const snapshots = new SnapshotMap(this.lsAndTsDocResolver, lsContainer); snapshots.set(tsDoc.filePath, tsDoc); if (!typeDefs) { diff --git a/packages/language-server/src/plugins/typescript/features/UpdateImportsProvider.ts b/packages/language-server/src/plugins/typescript/features/UpdateImportsProvider.ts index aa46594ff..54aa9a060 100644 --- a/packages/language-server/src/plugins/typescript/features/UpdateImportsProvider.ts +++ b/packages/language-server/src/plugins/typescript/features/UpdateImportsProvider.ts @@ -6,14 +6,27 @@ import { WorkspaceEdit } from 'vscode-languageserver'; import { mapRangeToOriginal } from '../../../lib/documents'; -import { urlToPath } from '../../../utils'; +import { + createGetCanonicalFileName, + GetCanonicalFileName, + normalizePath, + urlToPath +} from '../../../utils'; import { FileRename, UpdateImportsProvider } from '../../interfaces'; import { LSAndTSDocResolver } from '../LSAndTSDocResolver'; +import { forAllServices, LanguageServiceContainer } from '../service'; import { convertRange } from '../utils'; import { isKitTypePath, SnapshotMap } from './utils'; export class UpdateImportsProviderImpl implements UpdateImportsProvider { - constructor(private readonly lsAndTsDocResolver: LSAndTSDocResolver) {} + constructor( + private readonly lsAndTsDocResolver: LSAndTSDocResolver, + useCaseSensitiveFileNames: boolean + ) { + this.getCanonicalFileName = createGetCanonicalFileName(useCaseSensitiveFileNames); + } + + private getCanonicalFileName: GetCanonicalFileName; async updateImports(fileRename: FileRename): Promise { // TODO does this handle folder moves/renames correctly? old/new path isn't a file then @@ -23,7 +36,47 @@ export class UpdateImportsProviderImpl implements UpdateImportsProvider { return null; } - const ls = await this.getLSForPath(newPath); + const services: LanguageServiceContainer[] = []; + await forAllServices((ls) => { + services.push(ls); + }); + + const documentChanges = new Map(); + for (const service of services) { + await this.updateImportForSingleService(oldPath, newPath, service, documentChanges); + } + + return { + documentChanges: Array.from(documentChanges.values()) + }; + } + + async updateImportForSingleService( + oldPath: string, + newPath: string, + lsContainer: LanguageServiceContainer, + documentChanges: Map + ) { + const ls = lsContainer.getService(); + const program = ls.getProgram(); + if (!program) { + return; + } + + const canonicalOldPath = this.getCanonicalFileName(normalizePath(oldPath)); + const canonicalNewPath = this.getCanonicalFileName(normalizePath(newPath)); + const hasFile = program.getSourceFiles().some((sf) => { + const normalizedFileName = this.getCanonicalFileName(normalizePath(sf.fileName)); + return ( + normalizedFileName.startsWith(canonicalOldPath) || + normalizedFileName.startsWith(canonicalNewPath) + ); + }); + + if (!hasFile) { + return; + } + const oldPathTsProgramCasing = ls.getProgram()?.getSourceFile(oldPath)?.fileName ?? oldPath; // `getEditsForFileRename` might take a while const fileChanges = ls @@ -75,12 +128,15 @@ export class UpdateImportsProviderImpl implements UpdateImportsProvider { return change; }); - const docs = new SnapshotMap(this.lsAndTsDocResolver); - const documentChanges = await Promise.all( + const docs = new SnapshotMap(this.lsAndTsDocResolver, lsContainer); + await Promise.all( updateImportsChanges.map(async (change) => { + if (documentChanges.has(change.fileName)) { + return; + } const snapshot = await docs.retrieve(change.fileName); - return TextDocumentEdit.create( + const edit = TextDocumentEdit.create( OptionalVersionedTextDocumentIdentifier.create(snapshot.getURL(), null), change.textChanges.map((edit) => { const range = mapRangeToOriginal( @@ -90,13 +146,9 @@ export class UpdateImportsProviderImpl implements UpdateImportsProvider { return TextEdit.replace(range, edit.newText); }) ); + + documentChanges.set(change.fileName, edit); }) ); - - return { documentChanges }; - } - - private async getLSForPath(path: string) { - return this.lsAndTsDocResolver.getLSForPath(path); } } diff --git a/packages/language-server/src/plugins/typescript/features/utils.ts b/packages/language-server/src/plugins/typescript/features/utils.ts index f8ee48a21..dba13177a 100644 --- a/packages/language-server/src/plugins/typescript/features/utils.ts +++ b/packages/language-server/src/plugins/typescript/features/utils.ts @@ -12,6 +12,7 @@ import { LSAndTSDocResolver } from '../LSAndTSDocResolver'; import { or } from '../../../utils'; import { FileMap } from '../../../lib/documents/fileCollection'; import { LSConfig } from '../../../ls-config'; +import { LanguageServiceContainer } from '../service'; type NodePredicate = (node: ts.Node) => boolean; @@ -144,7 +145,10 @@ export function getStoreOffsetOf$storeDeclaration(text: string, $storeVarStart: export class SnapshotMap { private map = new FileMap(); - constructor(private resolver: LSAndTSDocResolver) {} + constructor( + private resolver: LSAndTSDocResolver, + private sourceLs: LanguageServiceContainer + ) {} set(fileName: string, snapshot: DocumentSnapshot) { this.map.set(fileName, snapshot); @@ -156,12 +160,18 @@ export class SnapshotMap { async retrieve(fileName: string) { let snapshot = this.get(fileName); - if (!snapshot) { - const snap = await this.resolver.getSnapshot(fileName); - this.set(fileName, snap); - snapshot = snap; + if (snapshot) { + return snapshot; } - return snapshot; + + const snap = + this.sourceLs.snapshotManager.get(fileName) ?? + // should not happen in most cases, + // the file should be in the project otherwise why would we know about it + (await this.resolver.getOrCreateSnapshot(fileName)); + + this.set(fileName, snap); + return snap; } } diff --git a/packages/language-server/src/plugins/typescript/module-loader.ts b/packages/language-server/src/plugins/typescript/module-loader.ts index 91e59827b..915e50b26 100644 --- a/packages/language-server/src/plugins/typescript/module-loader.ts +++ b/packages/language-server/src/plugins/typescript/module-loader.ts @@ -166,7 +166,8 @@ export function createSvelteModuleLoader( getSnapshot: (fileName: string) => DocumentSnapshot, compilerOptions: ts.CompilerOptions, tsSystem: ts.System, - tsModule: typeof ts + tsModule: typeof ts, + getModuleResolutionHost: () => ts.ModuleResolutionHost | undefined ) { const getCanonicalFileName = createGetCanonicalFileName(tsSystem.useCaseSensitiveFileNames); const svelteSys = createSvelteSys(tsSystem); @@ -206,9 +207,16 @@ export function createSvelteModuleLoader( const previousTriedButFailed = failedPathToContainingFile.get(path); - for (const containingFile of previousTriedButFailed ?? []) { + if (!previousTriedButFailed) { + return; + } + + for (const containingFile of previousTriedButFailed) { failedLocationInvalidated.add(containingFile); } + + tsModuleCache.clear(); + typeReferenceCache.clear(); }, resolveModuleNames, resolveTypeReferenceDirectiveReferences, @@ -221,8 +229,8 @@ export function createSvelteModuleLoader( moduleNames: string[], containingFile: string, _reusedNames: string[] | undefined, - _redirectedReference: ts.ResolvedProjectReference | undefined, - _options: ts.CompilerOptions, + redirectedReference: ts.ResolvedProjectReference | undefined, + options: ts.CompilerOptions, containingSourceFile?: ts.SourceFile | undefined ): Array { return moduleNames.map((moduleName, index) => { @@ -234,7 +242,9 @@ export function createSvelteModuleLoader( moduleName, containingFile, containingSourceFile, - index + index, + redirectedReference, + options ); resolvedModule?.failedLookupLocations?.forEach((failedLocation) => { @@ -252,60 +262,44 @@ export function createSvelteModuleLoader( name: string, containingFile: string, containingSourceFile: ts.SourceFile | undefined, - index: number + index: number, + redirectedReference: ts.ResolvedProjectReference | undefined, + option: ts.CompilerOptions ): ts.ResolvedModuleWithFailedLookupLocations { - const mode = impliedNodeFormatResolver.resolve( - name, - index, - containingSourceFile, - compilerOptions - ); - // Delegate to the TS resolver first. - // If that does not bring up anything, try the Svelte Module loader - // which is able to deal with .svelte files. - const tsResolvedModuleWithFailedLookup = tsModule.resolveModuleName( + const mode = impliedNodeFormatResolver.resolve(name, index, containingSourceFile, option); + const resolvedModuleWithFailedLookup = tsModule.resolveModuleName( name, containingFile, compilerOptions, - tsSystem, + getModuleResolutionHost() ?? svelteSys, tsModuleCache, - undefined, + redirectedReference, mode ); - const tsResolvedModule = tsResolvedModuleWithFailedLookup.resolvedModule; - if (tsResolvedModule) { - return tsResolvedModuleWithFailedLookup; + const resolvedModule = resolvedModuleWithFailedLookup.resolvedModule; + + if (!resolvedModule || !isVirtualSvelteFilePath(resolvedModule.resolvedFileName)) { + return resolvedModuleWithFailedLookup; } - const svelteResolvedModuleWithFailedLookup = tsModule.resolveModuleName( - name, - containingFile, - compilerOptions, - svelteSys, - undefined, - undefined, - mode + const resolvedFileName = svelteSys.getRealSveltePathIfExists( + resolvedModule.resolvedFileName ); - const svelteResolvedModule = svelteResolvedModuleWithFailedLookup.resolvedModule; - if ( - !svelteResolvedModule || - !isVirtualSvelteFilePath(svelteResolvedModule.resolvedFileName) - ) { - return svelteResolvedModuleWithFailedLookup; + if (!isSvelteFilePath(resolvedFileName)) { + return resolvedModuleWithFailedLookup; } - const resolvedFileName = ensureRealSvelteFilePath(svelteResolvedModule.resolvedFileName); const snapshot = getSnapshot(resolvedFileName); const resolvedSvelteModule: ts.ResolvedModuleFull = { extension: getExtensionFromScriptKind(snapshot && snapshot.scriptKind), resolvedFileName, - isExternalLibraryImport: svelteResolvedModule.isExternalLibraryImport + isExternalLibraryImport: resolvedModule.isExternalLibraryImport }; return { - ...svelteResolvedModuleWithFailedLookup, + ...resolvedModuleWithFailedLookup, resolvedModule: resolvedSvelteModule }; } diff --git a/packages/language-server/src/plugins/typescript/service.ts b/packages/language-server/src/plugins/typescript/service.ts index fa2fe0c2b..8f5720bb0 100644 --- a/packages/language-server/src/plugins/typescript/service.ts +++ b/packages/language-server/src/plugins/typescript/service.ts @@ -1,19 +1,25 @@ -import { basename, dirname, join, resolve } from 'path'; +import { dirname, join, resolve, basename } from 'path'; import ts from 'typescript'; -import { RelativePattern, TextDocumentContentChangeEvent } from 'vscode-languageserver-protocol'; +import { + PublishDiagnosticsParams, + RelativePattern, + TextDocumentContentChangeEvent +} from 'vscode-languageserver-protocol'; import { getPackageInfo, importSvelte } from '../../importPackage'; import { Document } from '../../lib/documents'; import { configLoader } from '../../lib/documents/configLoader'; import { FileMap, FileSet } from '../../lib/documents/fileCollection'; import { Logger } from '../../logger'; -import { createGetCanonicalFileName, normalizePath, pathToUrl, urlToPath } from '../../utils'; +import { + createGetCanonicalFileName, + isNotNullOrUndefined, + normalizePath, + pathToUrl, + urlToPath +} from '../../utils'; import { DocumentSnapshot, SvelteSnapshotOptions } from './DocumentSnapshot'; import { createSvelteModuleLoader } from './module-loader'; -import { - GlobalSnapshotsManager, - ignoredBuildDirectories, - SnapshotManager -} from './SnapshotManager'; +import { GlobalSnapshotsManager, SnapshotManager } from './SnapshotManager'; import { ensureRealSvelteFilePath, findTsConfigPath, @@ -27,15 +33,13 @@ export interface LanguageServiceContainer { readonly tsconfigPath: string; readonly compilerOptions: ts.CompilerOptions; readonly configErrors: ts.Diagnostic[]; - /** - * @internal Public for tests only - */ readonly snapshotManager: SnapshotManager; getService(skipSynchronize?: boolean): ts.LanguageService; updateSnapshot(documentOrFilePath: Document | string): DocumentSnapshot; deleteSnapshot(filePath: string): void; invalidateModuleCache(filePath: string[]): void; scheduleProjectFileUpdate(watcherNewFiles: string[]): void; + ensureProjectFileUpdates(): void; updateTsOrJsFile(fileName: string, changes?: TextDocumentContentChangeEvent[]): void; /** * Checks if a file is present in the project. @@ -50,7 +54,9 @@ export interface LanguageServiceContainer { onAutoImportProviderSettingsChanged(): void; onPackageJsonChange(packageJsonPath: string): void; getTsConfigSvelteOptions(): { namespace: string }; - + getResolvedProjectReferences(): TsConfigInfo[]; + openVirtualDocument(document: Document): void; + isShimFiles(filePath: string): boolean; dispose(): void; } @@ -63,7 +69,12 @@ declare module 'typescript' { */ hasInvalidatedResolutions?: (sourceFile: string) => boolean; + /** + * @internal + */ getModuleResolutionCache?(): ts.ModuleResolutionCache; + /** @internal */ + setCompilerHost?(host: ts.CompilerHost): void; } interface ResolvedModuleWithFailedLookupLocations { @@ -82,17 +93,26 @@ declare module 'typescript' { } } +export interface TsConfigInfo { + parsedCommandLine: ts.ParsedCommandLine; + snapshotManager: SnapshotManager; + pendingProjectFileUpdate: boolean; + configFilePath: string; + extendedConfigPaths?: Set; +} + const maxProgramSizeForNonTsFiles = 20 * 1024 * 1024; // 20 MB const services = new FileMap>(); const serviceSizeMap = new FileMap(); const configWatchers = new FileMap(); -const extendedConfigWatchers = new FileMap(); -const extendedConfigToTsConfigPath = new FileMap(); +const dependedConfigWatchers = new FileMap(); +const configPathToDependedProject = new FileMap(); const configFileModifiedTime = new FileMap(); const configFileForOpenFiles = new FileMap(); const pendingReloads = new FileSet(); const documentRegistries = new Map(); const pendingForAllServices = new Set>(); +const parsedTsConfigInfo = new FileMap(); /** * For testing only: Reset the cache for services. @@ -101,6 +121,7 @@ const pendingForAllServices = new Set>(); */ export function __resetCache() { services.clear(); + parsedTsConfigInfo.clear(); serviceSizeMap.clear(); configFileForOpenFiles.clear(); } @@ -112,7 +133,8 @@ export interface LanguageServiceDocumentContext { globalSnapshotsManager: GlobalSnapshotsManager; notifyExceedSizeLimit: (() => void) | undefined; extendedConfigCache: Map; - onProjectReloaded: (() => void) | undefined; + onProjectReloaded: ((configFileNames: string[]) => void) | undefined; + reportConfigError: ((diagnostics: PublishDiagnosticsParams) => void) | undefined; watchTsConfig: boolean; tsSystem: ts.System; projectService: ProjectService | undefined; @@ -129,13 +151,35 @@ export async function getService( docContext.tsSystem.useCaseSensitiveFileNames ); - const tsconfigPath = + const fileExistsWithCache = (fileName: string) => { + return ( + (parsedTsConfigInfo.has(fileName) && !pendingReloads.has(fileName)) || + docContext.tsSystem.fileExists(fileName) + ); + }; + + let tsconfigPath = configFileForOpenFiles.get(path) ?? - findTsConfigPath(path, workspaceUris, docContext.tsSystem.fileExists, getCanonicalFileName); + findTsConfigPath(path, workspaceUris, fileExistsWithCache, getCanonicalFileName); if (tsconfigPath) { - configFileForOpenFiles.set(path, tsconfigPath); - return getServiceForTsconfig(tsconfigPath, dirname(tsconfigPath), docContext); + /** + * Prevent infinite loop when the project reference is circular + */ + const triedTsConfig = new Set(); + const needAssign = !configFileForOpenFiles.has(path); + let service = await getConfiguredService(tsconfigPath); + if (!needAssign) { + return service; + } + + const defaultService = await findDefaultServiceForFile(service, triedTsConfig); + if (defaultService) { + configFileForOpenFiles.set(path, defaultService.tsconfigPath); + return defaultService; + } + + tsconfigPath = ''; } // Find closer boundary: workspace uri or node_modules @@ -156,6 +200,55 @@ export async function getService( docContext.tsSystem.getCurrentDirectory(), docContext ); + + function getConfiguredService(tsconfigPath: string) { + return getServiceForTsconfig(tsconfigPath, dirname(tsconfigPath), docContext); + } + + async function findDefaultServiceForFile( + service: LanguageServiceContainer, + triedTsConfig: Set + ): Promise { + service.ensureProjectFileUpdates(); + if (service.snapshotManager.isProjectFile(path)) { + return service; + } + if (triedTsConfig.has(service.tsconfigPath)) { + return; + } + + // TODO: maybe add support for ts 5.6's ancestor searching + return findDefaultFromProjectReferences(service, triedTsConfig); + } + + async function findDefaultFromProjectReferences( + service: LanguageServiceContainer, + triedTsConfig: Set + ) { + const projectReferences = service.getResolvedProjectReferences(); + if (projectReferences.length === 0) { + return undefined; + } + + let possibleSubPaths: string[] = []; + for (const ref of projectReferences) { + if (ref.snapshotManager.isProjectFile(path)) { + return getConfiguredService(ref.configFilePath); + } + + if (ref.parsedCommandLine.projectReferences?.length) { + possibleSubPaths.push(ref.configFilePath); + } + } + + for (const ref of possibleSubPaths) { + const subService = await getConfiguredService(ref); + const defaultService = await findDefaultServiceForFile(subService, triedTsConfig); + if (defaultService) { + return defaultService; + } + } + } } export async function forAllServices( @@ -182,6 +275,9 @@ export async function getServiceForTsconfig( workspacePath: string, docContext: LanguageServiceDocumentContext ): Promise { + if (tsconfigPath) { + tsconfigPath = normalizePath(tsconfigPath); + } const tsconfigPathOrWorkspacePath = tsconfigPath || workspacePath; const reloading = pendingReloads.has(tsconfigPath); @@ -190,6 +286,7 @@ export async function getServiceForTsconfig( if (reloading || !services.has(tsconfigPathOrWorkspacePath)) { if (reloading) { Logger.log('Reloading ts service at ', tsconfigPath, ' due to config updated'); + parsedTsConfigInfo.delete(tsconfigPath); } else { Logger.log('Initialize new ts service at ', tsconfigPath); } @@ -216,36 +313,31 @@ async function createLanguageService( ): Promise { const { tsSystem } = docContext; - const { - options: compilerOptions, - errors: configErrors, - fileNames: files, - raw, - extendedConfigPaths, - wildcardDirectories - } = getParsedConfig(); + const projectConfig = getParsedConfig(); + const { options: compilerOptions, raw, errors: configErrors } = projectConfig; const getCanonicalFileName = createGetCanonicalFileName(tsSystem.useCaseSensitiveFileNames); - watchWildCardDirectories(); - - // raw is the tsconfig merged with extending config - // see: https://github.com/microsoft/TypeScript/blob/08e4f369fbb2a5f0c30dee973618d65e6f7f09f8/src/compiler/commandLineParser.ts#L2537 - const snapshotManager = new SnapshotManager( - docContext.globalSnapshotsManager, - raw, - workspacePath, - tsSystem, - files, - wildcardDirectories - ); + watchWildCardDirectories(projectConfig); + + const snapshotManager = createSnapshotManager(projectConfig, tsconfigPath); // 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 svelteModuleLoader = createSvelteModuleLoader(getSnapshot, compilerOptions, tsSystem, ts); + const svelteModuleLoader = createSvelteModuleLoader( + getSnapshot, + compilerOptions, + tsSystem, + ts, + () => host?.getCompilerHost?.() + ); let svelteTsPath: string; + /** + * set and clear during program creation, shouldn't not be cached elsewhere + */ + let compilerHost: ts.CompilerHost | undefined; try { // For when svelte2tsx/svelte-check is part of node_modules, for example VS Code extension svelteTsPath = dirname(require.resolve(docContext.ambientTypesSource)); @@ -262,27 +354,12 @@ async function createLanguageService( ? importSvelte(tsconfigPath || workspacePath) : undefined; - const isSvelte3 = sveltePackageInfo.version.major === 3; - const svelteHtmlDeclaration = isSvelte3 - ? undefined - : join(sveltePackageInfo.path, 'svelte-html.d.ts'); - const svelteHtmlFallbackIfNotExist = - svelteHtmlDeclaration && tsSystem.fileExists(svelteHtmlDeclaration) - ? 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'] - : ['./svelte-shims-v4.d.ts', svelteHtmlFallbackIfNotExist, './svelte-native-jsx.d.ts'] - ).map((f) => tsSystem.resolvePath(resolve(svelteTsPath, f))); + const svelteTsxFiles = getSvelteShimFiles(); let languageServiceReducedMode = false; let projectVersion = 0; - let dirty = false; - let pendingProjectFileUpdate = false; + let dirty = projectConfig.fileNames.length > 0; const host: ts.LanguageServiceHost = { log: (message) => Logger.debug(`[ts] ${message}`), @@ -297,7 +374,10 @@ async function createLanguageService( readFile: svelteModuleLoader.readFile, resolveModuleNames: svelteModuleLoader.resolveModuleNames, readDirectory: svelteModuleLoader.readDirectory, + realpath: tsSystem.realpath, getDirectories: tsSystem.getDirectories, + getProjectReferences: () => projectConfig.projectReferences, + getParsedCommandLine, useCaseSensitiveFileNames: () => tsSystem.useCaseSensitiveFileNames, getScriptKind: (fileName: string) => getSnapshot(fileName).scriptKind, getProjectVersion: () => projectVersion.toString(), @@ -305,11 +385,17 @@ async function createLanguageService( resolveTypeReferenceDirectiveReferences: svelteModuleLoader.resolveTypeReferenceDirectiveReferences, hasInvalidatedResolutions: svelteModuleLoader.mightHaveInvalidatedResolutions, - getModuleResolutionCache: svelteModuleLoader.getModuleResolutionCache + getModuleResolutionCache: svelteModuleLoader.getModuleResolutionCache, + useSourceOfProjectReferenceRedirect() { + return !languageServiceReducedMode; + }, + setCompilerHost: (host) => (compilerHost = host), + getCompilerHost: () => compilerHost }; const documentRegistry = getOrCreateDocumentRegistry( - host.getCurrentDirectory(), + // this should mostly be a singleton while host.getCurrentDirectory() might be the directory where the tsconfig is + tsSystem.getCurrentDirectory(), tsSystem.useCaseSensitiveFileNames ); @@ -326,8 +412,7 @@ async function createLanguageService( docContext.globalSnapshotsManager.onChange(scheduleUpdate); reduceLanguageServiceCapabilityIfFileSizeTooBig(); - updateExtendedConfigDependents(); - watchConfigFile(); + watchConfigFiles(projectConfig.extendedConfigPaths, projectConfig); return { tsconfigPath, @@ -338,6 +423,7 @@ async function createLanguageService( deleteSnapshot, scheduleProjectFileUpdate, updateTsOrJsFile, + ensureProjectFileUpdates, hasFile, fileBelongsToProject, snapshotManager, @@ -345,10 +431,34 @@ async function createLanguageService( onAutoImportProviderSettingsChanged, onPackageJsonChange, getTsConfigSvelteOptions, + getResolvedProjectReferences, + openVirtualDocument, + isShimFiles, dispose }; - function watchWildCardDirectories() { + function createSnapshotManager( + parsedCommandLine: ts.ParsedCommandLine, + configFileName: string + ) { + const cached = configFileName ? parsedTsConfigInfo.get(configFileName) : undefined; + if (cached?.snapshotManager) { + return cached.snapshotManager; + } + // raw is the tsconfig merged with extending config + // see: https://github.com/microsoft/TypeScript/blob/08e4f369fbb2a5f0c30dee973618d65e6f7f09f8/src/compiler/commandLineParser.ts#L2537 + return new SnapshotManager( + docContext.globalSnapshotsManager, + parsedCommandLine.raw, + configFileName ? dirname(configFileName) : workspacePath, + tsSystem, + parsedCommandLine.fileNames.map(normalizePath), + parsedCommandLine.wildcardDirectories + ); + } + + function watchWildCardDirectories(parseCommandLine: ts.ParsedCommandLine) { + const { wildcardDirectories } = parseCommandLine; if (!wildcardDirectories || !docContext.watchDirectory) { return; } @@ -376,10 +486,8 @@ async function createLanguageService( } function getService(skipSynchronize?: boolean) { - if (pendingProjectFileUpdate) { - updateProjectFiles(); - pendingProjectFileUpdate = false; - } + ensureProjectFileUpdates(); + if (!skipSynchronize) { updateIfDirty(); } @@ -411,7 +519,13 @@ async function createLanguageService( function updateSnapshotFromDocument(document: Document): DocumentSnapshot { const filePath = document.getFilePath() || ''; const prevSnapshot = snapshotManager.get(filePath); - if (prevSnapshot?.version === document.version) { + + if ( + prevSnapshot?.version === document.version && + // In the test, there might be a new document instance with a different openedByClient + // In that case, Create a new snapshot otherwise the getClientFileNames won't include the new client file + prevSnapshot.isOpenedInClient() === document.openedByClient + ) { return prevSnapshot; } @@ -481,17 +595,38 @@ async function createLanguageService( } function scheduleProjectFileUpdate(watcherNewFiles: string[]): void { - if (snapshotManager.areIgnoredFromNewFileWatch(watcherNewFiles)) { - return; + if (!snapshotManager.areIgnoredFromNewFileWatch(watcherNewFiles)) { + scheduleUpdate(); + const info = parsedTsConfigInfo.get(tsconfigPath); + if (info) { + info.pendingProjectFileUpdate = true; + } } - scheduleUpdate(); - pendingProjectFileUpdate = true; + if (!projectConfig.projectReferences) { + return; + } + for (const ref of projectConfig.projectReferences) { + const config = parsedTsConfigInfo.get(ref.path); + if ( + config && + // handled by the respective service + !services.has(config.configFilePath) && + !config.snapshotManager.areIgnoredFromNewFileWatch(watcherNewFiles) + ) { + config.pendingProjectFileUpdate = true; + scheduleUpdate(); + } + } } - function updateProjectFiles(): void { + function ensureProjectFileUpdates(): void { + const info = parsedTsConfigInfo.get(tsconfigPath); + if (!info || !info.pendingProjectFileUpdate) { + return; + } const projectFileCountBefore = snapshotManager.getProjectFileNames().length; - snapshotManager.updateProjectFiles(); + ensureFilesForConfigUpdates(info); const projectFileCountAfter = snapshotManager.getProjectFileNames().length; if (projectFileCountAfter > projectFileCountBefore) { @@ -535,72 +670,26 @@ async function createLanguageService( } function getParsedConfig() { - const forcedCompilerOptions: ts.CompilerOptions = { - allowNonTsExtensions: true, - target: ts.ScriptTarget.Latest, - allowJs: true, - noEmit: true, - declaration: false, - skipLibCheck: true - }; - - // always let ts parse config to get default compilerOption - let configJson = - (tsconfigPath && ts.readConfigFile(tsconfigPath, tsSystem.readFile).config) || - getDefaultJsConfig(); - - // Only default exclude when no extends for now - if (!configJson.extends) { - configJson = Object.assign( - { - exclude: getDefaultExclude() - }, - configJson - ); - } - - const extendedConfigPaths = new Set(); - const { extendedConfigCache } = docContext; - const cacheMonitorProxy = { - ...docContext.extendedConfigCache, - get(key: string) { - extendedConfigPaths.add(key); - return extendedConfigCache.get(key); - }, - has(key: string) { - extendedConfigPaths.add(key); - return extendedConfigCache.has(key); - }, - set(key: string, value: ts.ExtendedConfigCacheEntry) { - extendedConfigPaths.add(key); - return extendedConfigCache.set(key, value); + let compilerOptions: ts.CompilerOptions; + let parsedConfig: ts.ParsedCommandLine; + let extendedConfigPaths: Set | undefined; + + if (tsconfigPath) { + const info = ensureTsConfigInfoUpToDate(tsconfigPath); + // tsconfig is either found from file-system or passed from svelte-check + // so this is already be validated to exist + if (!info) { + throw new Error('Failed to get tsconfig: ' + tsconfigPath); } - }; - - const parsedConfig = ts.parseJsonConfigFileContent( - configJson, - tsSystem, - workspacePath, - forcedCompilerOptions, - tsconfigPath, - undefined, - [ - { - extension: 'svelte', - isMixedContent: true, - // Deferred was added in a later TS version, fall back to tsx - // If Deferred exists, this means that all Svelte files are included - // in parsedConfig.fileNames - scriptKind: ts.ScriptKind.Deferred ?? ts.ScriptKind.TS - } - ], - cacheMonitorProxy - ); + compilerOptions = info.parsedCommandLine.options; + parsedConfig = info.parsedCommandLine; + extendedConfigPaths = info.extendedConfigPaths; + } else { + const config = parseDefaultCompilerOptions(); + compilerOptions = config.compilerOptions; + parsedConfig = config.parsedConfig; + } - const compilerOptions: ts.CompilerOptions = { - ...parsedConfig.options, - ...forcedCompilerOptions - }; if ( !compilerOptions.moduleResolution || compilerOptions.moduleResolution === ts.ModuleResolutionKind.Classic @@ -609,6 +698,7 @@ async function createLanguageService( // NodeJS: up to 4.9, Node10: since 5.0 (ts.ModuleResolutionKind as any).NodeJs ?? ts.ModuleResolutionKind.Node10; } + if ( !compilerOptions.module || [ @@ -623,6 +713,12 @@ async function createLanguageService( compilerOptions.module = ts.ModuleKind.ESNext; } + if (!compilerOptions.target) { + compilerOptions.target = ts.ScriptTarget.Latest; + } else if (ts.ScriptTarget.ES2015 > compilerOptions.target) { + compilerOptions.target = ts.ScriptTarget.ES2015; + } + // detect which JSX namespace to use (svelte | svelteNative) if not specified or not compatible if (!compilerOptions.jsxFactory || !compilerOptions.jsxFactory.startsWith('svelte')) { //override if we detect svelte-native @@ -640,6 +736,20 @@ async function createLanguageService( } } + const svelteConfigDiagnostics = checkSvelteInput(parsedConfig); + if (svelteConfigDiagnostics.length > 0) { + docContext.reportConfigError?.({ + uri: pathToUrl(tsconfigPath), + diagnostics: svelteConfigDiagnostics.map((d) => ({ + message: d.messageText as string, + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, + severity: ts.DiagnosticCategory.Error, + source: 'svelte' + })) + }); + parsedConfig.errors.push(...svelteConfigDiagnostics); + } + return { ...parsedConfig, fileNames: parsedConfig.fileNames.map(normalizePath), @@ -648,15 +758,43 @@ async function createLanguageService( }; } - /** - * This should only be used when there's no jsconfig/tsconfig at all - */ - function getDefaultJsConfig(): { - compilerOptions: ts.CompilerOptions; - include: string[]; - } { - return { + function checkSvelteInput(config: ts.ParsedCommandLine) { + if (!tsconfigPath || config.raw.references || config.raw.files) { + return []; + } + + const svelteFiles = config.fileNames.filter(isSvelteFilePath); + if (svelteFiles.length > 0) { + return []; + } + const { include, exclude } = config.raw; + const inputText = JSON.stringify(include); + const excludeText = JSON.stringify(exclude); + const svelteConfigDiagnostics: ts.Diagnostic[] = [ + { + category: ts.DiagnosticCategory.Error, + code: 0, + file: undefined, + start: undefined, + length: undefined, + messageText: + `No svelte input files were found in config file '${tsconfigPath}'. ` + + `Did you forget to add svelte files to the 'include' in your ${basename(tsconfigPath)}? ` + + `Specified 'include' paths were '${inputText}' and 'exclude' paths were '${excludeText}'`, + source: 'svelte' + } + ]; + + return svelteConfigDiagnostics; + } + + function parseDefaultCompilerOptions() { + let configJson = { compilerOptions: { + allowJs: true, + noEmit: true, + declaration: false, + skipLibCheck: true, maxNodeModuleJsDepth: 2, allowSyntheticDefaultImports: true }, @@ -664,10 +802,17 @@ async function createLanguageService( // with potentially completely unrelated .ts/.js files: include: [] }; - } - function getDefaultExclude() { - return ['node_modules', ...ignoredBuildDirectories]; + const parsedConfig = ts.parseJsonConfigFileContent(configJson, tsSystem, workspacePath); + + const compilerOptions: ts.CompilerOptions = { + ...parsedConfig.options, + target: ts.ScriptTarget.Latest, + allowNonTsExtensions: true, + moduleResolution: ts.ModuleResolutionKind.Node10 + }; + + return { compilerOptions, parsedConfig }; } /** @@ -694,6 +839,7 @@ async function createLanguageService( } function dispose() { + compilerHost = undefined; languageService.dispose(); snapshotManager.dispose(); configWatchers.get(tsconfigPath)?.close(); @@ -702,19 +848,23 @@ async function createLanguageService( docContext.globalSnapshotsManager.removeChangeListener(scheduleUpdate); } - function updateExtendedConfigDependents() { - extendedConfigPaths.forEach((extendedConfig) => { - let dependedTsConfig = extendedConfigToTsConfigPath.get(extendedConfig); + function watchConfigFiles( + extendedConfigPaths: Set | undefined, + parsedCommandLine: ts.ParsedCommandLine + ) { + const tsconfigDependencies = Array.from(extendedConfigPaths ?? []).concat( + parsedCommandLine.projectReferences?.map((r) => r.path) ?? [] + ); + tsconfigDependencies.forEach((configPath) => { + let dependedTsConfig = configPathToDependedProject.get(configPath); if (!dependedTsConfig) { dependedTsConfig = new FileSet(tsSystem.useCaseSensitiveFileNames); - extendedConfigToTsConfigPath.set(extendedConfig, dependedTsConfig); + configPathToDependedProject.set(configPath, dependedTsConfig); } dependedTsConfig.add(tsconfigPath); }); - } - function watchConfigFile() { if (!tsSystem.watchFile || !docContext.watchTsConfig) { return; } @@ -728,16 +878,16 @@ async function createLanguageService( ); } - for (const config of extendedConfigPaths) { - if (extendedConfigWatchers.has(config)) { + for (const config of tsconfigDependencies) { + if (dependedConfigWatchers.has(config)) { continue; } configFileModifiedTime.set(config, tsSystem.getModifiedTime?.(config)); - extendedConfigWatchers.set( + dependedConfigWatchers.set( config, // for some reason setting the polling interval is necessary, else some error in TS is thrown - tsSystem.watchFile(config, createWatchExtendedConfigCallback(docContext), 1000) + tsSystem.watchFile(config, createWatchDependedConfigCallback(docContext), 1000) ); } } @@ -763,7 +913,8 @@ async function createLanguageService( configFileForOpenFiles.clear(); } - docContext.onProjectReloaded?.(); + docContext.onProjectReloaded?.([fileName]); + docContext.reportConfigError?.({ uri: pathToUrl(fileName), diagnostics: [] }); } function updateIfDirty() { @@ -780,6 +931,7 @@ async function createLanguageService( } dirty = false; + compilerHost = undefined; // https://github.com/microsoft/TypeScript/blob/23faef92703556567ddbcb9afb893f4ba638fc20/src/server/project.ts#L1624 // host.getCachedExportInfoMap will create the cache if it doesn't exist @@ -791,6 +943,9 @@ async function createLanguageService( } exportMapCache.releaseSymbols(); + // https://github.com/microsoft/TypeScript/blob/941d1543c201e40d87e63c9db04818493afdd9e7/src/server/project.ts#L1731 + // if one file change results in clearing the cache + // don't continue to check other files, this will mark the cache as usable while it's empty for (const fileName of changedFilesForExportCache) { const oldFile = oldProgram.getSourceFile(fileName); const newFile = program?.getSourceFile(fileName); @@ -800,11 +955,15 @@ async function createLanguageService( continue; } - if (oldFile && newFile) { - exportMapCache.onFileChanged?.(oldFile, newFile, false); - } else { + if (!oldFile || !newFile) { // new file or deleted file exportMapCache.clear(); + break; + } + + const cleared = exportMapCache.onFileChanged?.(oldFile, newFile, false); + if (cleared) { + break; } } changedFilesForExportCache.clear(); @@ -875,6 +1034,141 @@ async function createLanguageService( namespace: transformationConfig.typingsNamespace }; } + + function ensureTsConfigInfoUpToDate(configFilePath: string) { + const cached = parsedTsConfigInfo.get(configFilePath); + if (cached !== undefined) { + ensureFilesForConfigUpdates(cached); + return cached; + } + + const content = tsSystem.fileExists(configFilePath) && tsSystem.readFile(configFilePath); + if (!content) { + parsedTsConfigInfo.set(configFilePath, null); + return null; + } + + const json = ts.parseJsonText(configFilePath, content); + + const extendedConfigPaths = new Set(); + const { extendedConfigCache } = docContext; + const cacheMonitorProxy = { + ...docContext.extendedConfigCache, + get(key: string) { + extendedConfigPaths.add(key); + return extendedConfigCache.get(key); + }, + has(key: string) { + extendedConfigPaths.add(key); + return extendedConfigCache.has(key); + }, + set(key: string, value: ts.ExtendedConfigCacheEntry) { + extendedConfigPaths.add(key); + return extendedConfigCache.set(key, value); + } + }; + + // TypeScript will throw if the parsedCommandLine doesn't include the sourceFile for the config file + // i.e. it must be directly parse from the json text instead of a javascript object like we do in getParsedConfig + const parsedCommandLine = ts.parseJsonSourceFileConfigFileContent( + json, + tsSystem, + dirname(configFilePath), + /*existingOptions*/ undefined, + configFilePath, + /*resolutionStack*/ undefined, + [ + { + extension: 'svelte', + isMixedContent: true, + // Deferred was added in a later TS version, fall back to tsx + // If Deferred exists, this means that all Svelte files are included + // in parsedConfig.fileNames + scriptKind: ts.ScriptKind.Deferred ?? ts.ScriptKind.TS + } + ], + cacheMonitorProxy + ); + + parsedCommandLine.options.allowNonTsExtensions = true; + + const snapshotManager = createSnapshotManager(parsedCommandLine, configFilePath); + + const tsconfigInfo: TsConfigInfo = { + parsedCommandLine, + snapshotManager, + pendingProjectFileUpdate: false, + configFilePath, + extendedConfigPaths + }; + parsedTsConfigInfo.set(configFilePath, tsconfigInfo); + + watchConfigFiles(extendedConfigPaths, parsedCommandLine); + + return tsconfigInfo; + } + + function getParsedCommandLine(configFilePath: string) { + return ensureTsConfigInfoUpToDate(configFilePath)?.parsedCommandLine; + } + + function ensureFilesForConfigUpdates(info: TsConfigInfo | null) { + if (info?.pendingProjectFileUpdate) { + info.pendingProjectFileUpdate = false; + info.snapshotManager.updateProjectFiles(); + info.parsedCommandLine.fileNames = info.snapshotManager.getProjectFileNames(); + } + } + + function getResolvedProjectReferences(): TsConfigInfo[] { + if (!tsconfigPath || !projectConfig.projectReferences) { + return []; + } + + return projectConfig.projectReferences + .map((ref) => ensureTsConfigInfoUpToDate(normalizePath(ref.path))) + .filter(isNotNullOrUndefined); + } + + function openVirtualDocument(document: Document) { + const filePath = document.getFilePath(); + if (!filePath) { + return; + } + configFileForOpenFiles.set(filePath, tsconfigPath || workspacePath); + updateSnapshot(document); + scheduleUpdate(filePath); + } + + function getSvelteShimFiles() { + const isSvelte3 = sveltePackageInfo.version.major === 3; + const svelteHtmlDeclaration = isSvelte3 + ? undefined + : join(sveltePackageInfo.path, 'svelte-html.d.ts'); + const svelteHtmlFallbackIfNotExist = + svelteHtmlDeclaration && tsSystem.fileExists(svelteHtmlDeclaration) + ? svelteHtmlDeclaration + : './svelte-jsx-v4.d.ts'; + + const svelteTsxFiles = ( + isSvelte3 + ? ['./svelte-shims.d.ts', './svelte-jsx.d.ts', './svelte-native-jsx.d.ts'] + : [ + './svelte-shims-v4.d.ts', + svelteHtmlFallbackIfNotExist, + './svelte-native-jsx.d.ts' + ] + ).map((f) => tsSystem.resolvePath(resolve(svelteTsPath, f))); + + const result = new FileSet(tsSystem.useCaseSensitiveFileNames); + + svelteTsxFiles.forEach((f) => result.add(normalizePath(f))); + return result; + } + + function isShimFiles(filePath: string) { + return svelteTsxFiles.has(normalizePath(filePath)); + } } /** @@ -934,7 +1228,7 @@ function exceedsTotalSizeLimitForNonTsFiles( * because it would reference the closure * So that GC won't drop it and cause memory leaks */ -function createWatchExtendedConfigCallback(docContext: LanguageServiceDocumentContext) { +function createWatchDependedConfigCallback(docContext: LanguageServiceDocumentContext) { return async ( fileName: string, kind: ts.FileWatcherEventKind, @@ -950,10 +1244,18 @@ function createWatchExtendedConfigCallback(docContext: LanguageServiceDocumentCo return; } + const getCanonicalFileName = createGetCanonicalFileName( + docContext.tsSystem.useCaseSensitiveFileNames + ); + + docContext.extendedConfigCache.delete(getCanonicalFileName(fileName)); + // rely on TypeScript internal behavior so delete both just in case docContext.extendedConfigCache.delete(fileName); - const promises = Array.from(extendedConfigToTsConfigPath.get(fileName) ?? []).map( + const reloadingConfigs: string[] = []; + const promises = Array.from(configPathToDependedProject.get(fileName) ?? []).map( async (config) => { + reloadingConfigs.push(config); const oldService = services.get(config); scheduleReload(config); (await oldService)?.dispose(); @@ -961,7 +1263,7 @@ function createWatchExtendedConfigCallback(docContext: LanguageServiceDocumentCo ); await Promise.all(promises); - docContext.onProjectReloaded?.(); + docContext.onProjectReloaded?.(reloadingConfigs); }; } diff --git a/packages/language-server/src/plugins/typescript/serviceCache.ts b/packages/language-server/src/plugins/typescript/serviceCache.ts index 379a0306f..d91b05b32 100644 --- a/packages/language-server/src/plugins/typescript/serviceCache.ts +++ b/packages/language-server/src/plugins/typescript/serviceCache.ts @@ -81,8 +81,10 @@ export function createProject( 'getSymlinkCache', 'getPackageJsonsVisibleToFile', 'getPackageJsonAutoImportProvider', - 'includePackageJsonAutoImports', - 'useSourceOfProjectReferenceRedirect' + 'includePackageJsonAutoImports' + // Volar doesn't have the "languageServiceReducedMode" support but we do + // so don't proxy this method and implement this directly in the ts.LanguageServiceHost + // 'useSourceOfProjectReferenceRedirect' ]; proxyMethods.forEach((key) => ((host as any)[key] = project[key].bind(project))); diff --git a/packages/language-server/src/plugins/typescript/svelte-sys.ts b/packages/language-server/src/plugins/typescript/svelte-sys.ts index 4fb3c205c..a639b2a58 100644 --- a/packages/language-server/src/plugins/typescript/svelte-sys.ts +++ b/packages/language-server/src/plugins/typescript/svelte-sys.ts @@ -20,12 +20,18 @@ export function createSvelteSys(tsSystem: ts.System) { } } + function getRealSveltePathIfExists(path: string) { + return svelteFileExists(path) ? toRealSvelteFilePath(path) : path; + } + const svelteSys: ts.System & { deleteFromCache: (path: string) => void; svelteFileExists: (path: string) => boolean; + getRealSveltePathIfExists: (path: string) => string; } = { ...tsSystem, svelteFileExists, + getRealSveltePathIfExists, fileExists(path: string) { // We need to check both .svelte and .svelte.ts/js because that's how Svelte 5 will likely mark files with runes in them const sveltePathExists = svelteFileExists(path); @@ -36,7 +42,7 @@ export function createSvelteSys(tsSystem: ts.System) { }, readFile(path: string) { // No getSnapshot here, because TS will very rarely call this and only for files that are not in the project - return tsSystem.readFile(svelteFileExists(path) ? toRealSvelteFilePath(path) : path); + return tsSystem.readFile(getRealSveltePathIfExists(path)); }, readDirectory(path, extensions, exclude, include, depth) { const extensionsWithSvelte = extensions ? [...extensions, '.svelte'] : undefined; diff --git a/packages/language-server/src/server.ts b/packages/language-server/src/server.ts index 3fffda855..d19e56dcc 100644 --- a/packages/language-server/src/server.ts +++ b/packages/language-server/src/server.ts @@ -199,7 +199,10 @@ export function startServer(options?: LSOptions) { onProjectReloaded: refreshCrossFilesSemanticFeatures, watch: true, nonRecursiveWatchPattern, - watchDirectory: (patterns) => watchDirectory(patterns) + watchDirectory: (patterns) => watchDirectory(patterns), + reportConfigError(diagnostic) { + connection?.sendDiagnostics(diagnostic); + } }), normalizedWorkspaceUris, docManager diff --git a/packages/language-server/src/svelte-check.ts b/packages/language-server/src/svelte-check.ts index b6100e60c..de8e54f56 100644 --- a/packages/language-server/src/svelte-check.ts +++ b/packages/language-server/src/svelte-check.ts @@ -18,6 +18,7 @@ import { JSOrTSDocumentSnapshot } from './plugins/typescript/DocumentSnapshot'; import { isInGeneratedCode } from './plugins/typescript/features/utils'; import { convertRange, getDiagnosticTag, mapSeverity } from './plugins/typescript/utils'; import { pathToUrl, urlToPath } from './utils'; +import { groupBy } from 'lodash'; export type SvelteCheckDiagnosticSource = 'js' | 'css' | 'svelte'; @@ -188,10 +189,36 @@ export class SvelteCheck { private async getDiagnosticsForTsconfig(tsconfigPath: string) { const lsContainer = await this.getLSContainer(tsconfigPath); + const map = (diagnostic: ts.Diagnostic, range?: Range): Diagnostic => { + const file = diagnostic.file; + range ??= file + ? convertRange( + { positionAt: file.getLineAndCharacterOfPosition.bind(file) }, + diagnostic + ) + : { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }; - const noInputsFoundError = lsContainer.configErrors?.find((e) => e.code === 18003); - if (noInputsFoundError) { - throw new Error(noInputsFoundError.messageText.toString()); + return { + range: range, + severity: mapSeverity(diagnostic.category), + source: diagnostic.source, + message: ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'), + code: diagnostic.code, + tags: getDiagnosticTag(diagnostic) + }; + }; + + if (lsContainer.configErrors) { + const grouped = groupBy( + lsContainer.configErrors, + (error) => error.file?.fileName ?? tsconfigPath + ); + + return Object.entries(grouped).map(([filePath, errors]) => ({ + filePath, + text: '', + diagnostics: errors.map((diagnostic) => map(diagnostic)) + })); } const lang = lsContainer.getService(); @@ -211,6 +238,7 @@ export class SvelteCheck { const skipDiagnosticsForFile = (options.skipLibCheck && file.isDeclarationFile) || (options.skipDefaultLibCheck && file.hasNoDefaultLib) || + lsContainer.isShimFiles(file.fileName) || // ignore JS files in node_modules /\/node_modules\/.+\.(c|m)?js$/.test(file.fileName); const snapshot = lsContainer.snapshotManager.get(file.fileName) as @@ -218,20 +246,6 @@ export class SvelteCheck { | undefined; const isKitFile = snapshot?.kitFile ?? false; const diagnostics: Diagnostic[] = []; - const map = (diagnostic: ts.Diagnostic, range?: Range) => ({ - range: - range ?? - convertRange( - { positionAt: file.getLineAndCharacterOfPosition.bind(file) }, - diagnostic - ), - severity: mapSeverity(diagnostic.category), - source: diagnostic.source, - message: ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'), - code: diagnostic.code, - tags: getDiagnosticTag(diagnostic) - }); - if (!skipDiagnosticsForFile) { const originalDiagnostics = [ ...lang.getSyntacticDiagnostics(file.fileName), diff --git a/packages/language-server/test/plugins/typescript/features/DiagnosticsProvider.test.ts b/packages/language-server/test/plugins/typescript/features/DiagnosticsProvider.test.ts index 92601459f..dbccee6e5 100644 --- a/packages/language-server/test/plugins/typescript/features/DiagnosticsProvider.test.ts +++ b/packages/language-server/test/plugins/typescript/features/DiagnosticsProvider.test.ts @@ -98,7 +98,7 @@ describe('DiagnosticsProvider', function () { ); const newFilePath = normalizePath(path.join(testDir, 'empty-export.ts')) || ''; - await lsAndTsDocResolver.getSnapshot(newFilePath); + await lsAndTsDocResolver.getOrCreateSnapshot(newFilePath); const diagnostics1 = await plugin.getDiagnostics(document); assert.deepStrictEqual( diff --git a/packages/language-server/test/plugins/typescript/features/FindReferencesProvider.test.ts b/packages/language-server/test/plugins/typescript/features/FindReferencesProvider.test.ts index ecd5d4f5b..12509c1c6 100644 --- a/packages/language-server/test/plugins/typescript/features/FindReferencesProvider.test.ts +++ b/packages/language-server/test/plugins/typescript/features/FindReferencesProvider.test.ts @@ -31,7 +31,11 @@ describe('FindReferencesProvider', function () { (textDocument) => new Document(textDocument.uri, textDocument.text) ); const lsConfigManager = new LSConfigManager(); - const lsAndTsDocResolver = new LSAndTSDocResolver(docManager, [testDir], lsConfigManager); + const lsAndTsDocResolver = new LSAndTSDocResolver( + docManager, + [pathToUrl(testDir)], + lsConfigManager + ); const provider = new FindReferencesProviderImpl( lsAndTsDocResolver, new FindComponentReferencesProviderImpl(lsAndTsDocResolver) diff --git a/packages/language-server/test/plugins/typescript/features/HoverProvider.test.ts b/packages/language-server/test/plugins/typescript/features/HoverProvider.test.ts index 17b6d8c35..49e146108 100644 --- a/packages/language-server/test/plugins/typescript/features/HoverProvider.test.ts +++ b/packages/language-server/test/plugins/typescript/features/HoverProvider.test.ts @@ -25,7 +25,11 @@ describe('HoverProvider', function () { (textDocument) => new Document(textDocument.uri, textDocument.text) ); const lsConfigManager = new LSConfigManager(); - const lsAndTsDocResolver = new LSAndTSDocResolver(docManager, [testDir], lsConfigManager); + const lsAndTsDocResolver = new LSAndTSDocResolver( + docManager, + [pathToUrl(testDir)], + lsConfigManager + ); const provider = new HoverProviderImpl(lsAndTsDocResolver); const document = openDoc(filename); return { provider, document }; diff --git a/packages/language-server/test/plugins/typescript/features/ImplemenationProvider.test.ts b/packages/language-server/test/plugins/typescript/features/ImplemenationProvider.test.ts index 57ad86192..152542342 100644 --- a/packages/language-server/test/plugins/typescript/features/ImplemenationProvider.test.ts +++ b/packages/language-server/test/plugins/typescript/features/ImplemenationProvider.test.ts @@ -29,7 +29,7 @@ describe('ImplementationProvider', function () { ); const lsAndTsDocResolver = new LSAndTSDocResolver( docManager, - [testDir], + [pathToUrl(testDir)], new LSConfigManager() ); const provider = new ImplementationProviderImpl(lsAndTsDocResolver); diff --git a/packages/language-server/test/plugins/typescript/features/TypeDefinitionProvider.test.ts b/packages/language-server/test/plugins/typescript/features/TypeDefinitionProvider.test.ts index f4e0baa44..e709bbd3c 100644 --- a/packages/language-server/test/plugins/typescript/features/TypeDefinitionProvider.test.ts +++ b/packages/language-server/test/plugins/typescript/features/TypeDefinitionProvider.test.ts @@ -29,7 +29,7 @@ describe('TypeDefinitionProvider', function () { ); const lsAndTsDocResolver = new LSAndTSDocResolver( docManager, - [testDir], + [pathToUrl(testDir)], new LSConfigManager() ); const provider = new TypeDefinitionProviderImpl(lsAndTsDocResolver); diff --git a/packages/language-server/test/plugins/typescript/features/UpdateImportsProvider.test.ts b/packages/language-server/test/plugins/typescript/features/UpdateImportsProvider.test.ts index 998896aa3..cdba08f4b 100644 --- a/packages/language-server/test/plugins/typescript/features/UpdateImportsProvider.test.ts +++ b/packages/language-server/test/plugins/typescript/features/UpdateImportsProvider.test.ts @@ -33,7 +33,10 @@ describe('UpdateImportsProviderImpl', function () { new LSConfigManager(), { tsSystem: { ...ts.sys, useCaseSensitiveFileNames } } ); - const updateImportsProvider = new UpdateImportsProviderImpl(lsAndTsDocResolver); + const updateImportsProvider = new UpdateImportsProviderImpl( + lsAndTsDocResolver, + useCaseSensitiveFileNames + ); const filePath = join(updateImportTestDir, filename); const fileUri = pathToUrl(filePath); const document = docManager.openClientDocument({ diff --git a/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/project-reference/nested/expectedv2.json b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/project-reference/nested/expectedv2.json new file mode 100644 index 000000000..ddef317a6 --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/project-reference/nested/expectedv2.json @@ -0,0 +1,10 @@ +[ + { + "range": { "start": { "line": 6, "character": 8 }, "end": { "line": 6, "character": 9 } }, + "severity": 1, + "source": "ts", + "message": "Type 'string' is not assignable to type 'number'.", + "code": 2322, + "tags": [] + } +] diff --git a/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/project-reference/nested/imported.ts b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/project-reference/nested/imported.ts new file mode 100644 index 000000000..bc8481bbb --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/project-reference/nested/imported.ts @@ -0,0 +1,5 @@ +/** + * + * @param cb callback because if the module resolution failed there will be a noImplicitAny error + */ +export function hi(cb: (num: number) => string) {} diff --git a/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/project-reference/nested/input.svelte b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/project-reference/nested/input.svelte new file mode 100644 index 000000000..3b505cd2c --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/project-reference/nested/input.svelte @@ -0,0 +1,8 @@ + diff --git a/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/project-reference/nested/tsconfig_sub2.json b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/project-reference/nested/tsconfig_sub2.json new file mode 100644 index 000000000..310ef4967 --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/project-reference/nested/tsconfig_sub2.json @@ -0,0 +1,7 @@ +{ + "include": [], + "compilerOptions": { + "composite": true + }, + "references": [{ "path": "./tsconfig_sub3.json" }] +} diff --git a/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/project-reference/nested/tsconfig_sub3.json b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/project-reference/nested/tsconfig_sub3.json new file mode 100644 index 000000000..f96815577 --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/project-reference/nested/tsconfig_sub3.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "strict": true, + "paths": { + "hi2": ["./imported.ts"] + }, + "types": [] + } +} diff --git a/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/project-reference/paths/expectedv2.json b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/project-reference/paths/expectedv2.json new file mode 100644 index 000000000..3d707cc47 --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/project-reference/paths/expectedv2.json @@ -0,0 +1,10 @@ +[ + { + "range": { "start": { "line": 6, "character": 4 }, "end": { "line": 6, "character": 5 } }, + "severity": 1, + "source": "ts", + "message": "Type 'string' is not assignable to type 'number'.", + "code": 2322, + "tags": [] + } +] diff --git a/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/project-reference/paths/imported.ts b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/project-reference/paths/imported.ts new file mode 100644 index 000000000..bc8481bbb --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/project-reference/paths/imported.ts @@ -0,0 +1,5 @@ +/** + * + * @param cb callback because if the module resolution failed there will be a noImplicitAny error + */ +export function hi(cb: (num: number) => string) {} diff --git a/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/project-reference/paths/input.svelte b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/project-reference/paths/input.svelte new file mode 100644 index 000000000..5e19507ef --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/project-reference/paths/input.svelte @@ -0,0 +1,8 @@ + diff --git a/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/project-reference/paths/tsconfig_sub.json b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/project-reference/paths/tsconfig_sub.json new file mode 100644 index 000000000..384062fc0 --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/project-reference/paths/tsconfig_sub.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "composite": true, + "strict": true, + "paths": { + "hi": ["./imported.ts"] + }, + "types": [] + } +} diff --git a/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/project-reference/tsconfig.json b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/project-reference/tsconfig.json new file mode 100644 index 000000000..59b56bf8f --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/project-reference/tsconfig.json @@ -0,0 +1,11 @@ +{ + "include": [], + "references": [ + { + "path": "./paths/tsconfig_sub.json" + }, + { + "path": "./nested/tsconfig_sub2.json" + } + ] +} diff --git a/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/tsconfig.json b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/tsconfig.json index f8add4155..da66f1d47 100644 --- a/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/tsconfig.json +++ b/packages/language-server/test/plugins/typescript/features/diagnostics/fixtures/tsconfig.json @@ -1,11 +1,13 @@ { "compilerOptions": { "strict": true, + "allowJs": true, + "target": "ESNext", /** This is actually not needed, but makes the tests faster because TS does not look up other types. */ "types": ["svelte"] }, - "exclude": ["./svelte-native/**/*", "./node16/**/*"] + "exclude": ["./svelte-native/**/*", "./node16/**/*", "project-reference/**/*"] } diff --git a/packages/language-server/test/plugins/typescript/features/inlayHints/fixtures/tsconfig.json b/packages/language-server/test/plugins/typescript/features/inlayHints/fixtures/tsconfig.json new file mode 100644 index 000000000..f493a66ae --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/inlayHints/fixtures/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "allowJs": true, + "target": "ESNext", + "strict": true, + /** + This is actually not needed, but makes the tests faster + because TS does not look up other types. + */ + "types": ["svelte"] + } +} diff --git a/packages/language-server/test/plugins/typescript/module-loader.test.ts b/packages/language-server/test/plugins/typescript/module-loader.test.ts index d08e42682..559eeb447 100644 --- a/packages/language-server/test/plugins/typescript/module-loader.test.ts +++ b/packages/language-server/test/plugins/typescript/module-loader.test.ts @@ -21,8 +21,11 @@ describe('createSvelteModuleLoader', () => { const moduleCacheMock = { getPackageJsonInfoCache: () => ({}) }; + const moduleResolutionHost = { ...ts.sys }; - const svelteSys = 'svelteSys'; + const svelteSys = { + ...svS.createSvelteSys(ts.sys) + }; sinon.stub(svS, 'createSvelteSys').returns(svelteSys); const compilerOptions: ts.CompilerOptions = { strict: true, paths: { '/@/*': [] } }; @@ -34,7 +37,8 @@ describe('createSvelteModuleLoader', () => { ...ts, createModuleResolutionCache: () => moduleCacheMock, resolveModuleName: resolveStub - } + }, + () => moduleResolutionHost ); return { @@ -43,7 +47,8 @@ describe('createSvelteModuleLoader', () => { resolveStub, compilerOptions, moduleResolver, - svelteSys + svelteSys, + moduleResolutionHost }; } @@ -51,132 +56,18 @@ describe('createSvelteModuleLoader', () => { return stub.getCall(stub.getCalls().length - 1); } - it('uses tsSys for normal files', async () => { - const resolvedModule: ts.ResolvedModuleFull = { - extension: ts.Extension.Ts, - resolvedFileName: 'filename.ts' - }; - const { resolveStub, moduleResolver, compilerOptions, moduleCacheMock } = - setup(resolvedModule); - const result = moduleResolver.resolveModuleNames( - ['./normal.ts'], - 'C:/somerepo/somefile.svelte', - undefined, - undefined, - undefined as any - ); - - assert.deepStrictEqual(result, [resolvedModule]); - assert.deepStrictEqual(lastCall(resolveStub).args, [ - './normal.ts', - 'C:/somerepo/somefile.svelte', - compilerOptions, - ts.sys, - moduleCacheMock, - undefined, - undefined - ]); - }); - - it('uses tsSys for normal files part of TS aliases', async () => { - const resolvedModule: ts.ResolvedModuleFull = { - extension: ts.Extension.Ts, - resolvedFileName: 'filename.ts' - }; - const { resolveStub, moduleResolver, compilerOptions, moduleCacheMock } = - setup(resolvedModule); - const result = moduleResolver.resolveModuleNames( - ['/@/normal'], - 'C:/somerepo/somefile.svelte', - undefined, - undefined, - undefined as any - ); - - assert.deepStrictEqual(result, [resolvedModule]); - assert.deepStrictEqual(lastCall(resolveStub).args, [ - '/@/normal', - 'C:/somerepo/somefile.svelte', - compilerOptions, - ts.sys, - moduleCacheMock, - undefined, - undefined - ]); - }); - - it('uses tsSys for svelte.d.ts files', async () => { - const resolvedModule: ts.ResolvedModuleFull = { - extension: ts.Extension.Dts, - resolvedFileName: 'filename.d.ts' - }; - const { resolveStub, moduleResolver, compilerOptions, moduleCacheMock } = - setup(resolvedModule); - const result = moduleResolver.resolveModuleNames( - ['./normal.ts'], - 'C:/somerepo/somefile.svelte', - undefined, - undefined, - undefined as any - ); - - assert.deepStrictEqual(result, [resolvedModule]); - assert.deepStrictEqual(lastCall(resolveStub).args, [ - './normal.ts', - 'C:/somerepo/somefile.svelte', - compilerOptions, - ts.sys, - moduleCacheMock, - undefined, - undefined - ]); - }); - - it('uses svelte module loader for virtual svelte files', async () => { + it('uses svelte script kind if resolved module is svelte file', async () => { const resolvedModule: ts.ResolvedModuleFull = { extension: ts.Extension.Ts, resolvedFileName: 'filename.svelte.ts' }; - const { resolveStub, svelteSys, moduleResolver, compilerOptions, getSvelteSnapshotStub } = - setup(resolvedModule); - resolveStub.onFirstCall().returns({ resolvedModule: undefined }); - const result = moduleResolver.resolveModuleNames( - ['./svelte.svelte'], - 'C:/somerepo/somefile.svelte', - undefined, - undefined, - undefined as any - ); + const { getSvelteSnapshotStub, moduleResolver, svelteSys } = setup(resolvedModule); - assert.deepStrictEqual(result, [ - { - extension: ts.Extension.Jsx, - resolvedFileName: 'filename.svelte', - isExternalLibraryImport: undefined - } - ]); - assert.deepStrictEqual(lastCall(resolveStub).args, [ - './svelte.svelte', - 'C:/somerepo/somefile.svelte', - compilerOptions, - svelteSys, - undefined, - undefined, - undefined - ]); - assert.deepStrictEqual(lastCall(getSvelteSnapshotStub).args, ['filename.svelte']); - }); + svelteSys.getRealSveltePathIfExists = (filename: string) => + filename === 'filename.svelte.ts' ? 'filename.svelte' : filename; - it('uses svelte module loader for virtual svelte files with TS path aliases', async () => { - const resolvedModule: ts.ResolvedModuleFull = { - extension: ts.Extension.Ts, - resolvedFileName: 'filename.svelte.ts' - }; - const { resolveStub, svelteSys, moduleResolver, compilerOptions, getSvelteSnapshotStub } = - setup(resolvedModule); - resolveStub.onFirstCall().returns({ resolvedModule: undefined }); const result = moduleResolver.resolveModuleNames( - ['/@/svelte.svelte'], + ['./normal.ts'], 'C:/somerepo/somefile.svelte', undefined, undefined, @@ -190,15 +81,6 @@ describe('createSvelteModuleLoader', () => { isExternalLibraryImport: undefined } ]); - assert.deepStrictEqual(lastCall(resolveStub).args, [ - '/@/svelte.svelte', - 'C:/somerepo/somefile.svelte', - compilerOptions, - svelteSys, - undefined, - undefined, - undefined - ]); assert.deepStrictEqual(lastCall(getSvelteSnapshotStub).args, ['filename.svelte']); }); diff --git a/packages/language-server/test/plugins/typescript/service.test.ts b/packages/language-server/test/plugins/typescript/service.test.ts index 02cac2d21..50c793c6f 100644 --- a/packages/language-server/test/plugins/typescript/service.test.ts +++ b/packages/language-server/test/plugins/typescript/service.test.ts @@ -1,16 +1,17 @@ import assert from 'assert'; import path from 'path'; +import sinon from 'sinon'; import ts from 'typescript'; +import { RelativePattern } from 'vscode-languageserver-protocol'; import { Document } from '../../../src/lib/documents'; import { GlobalSnapshotsManager } from '../../../src/plugins/typescript/SnapshotManager'; import { + LanguageServiceContainer, LanguageServiceDocumentContext, getService } from '../../../src/plugins/typescript/service'; -import { pathToUrl } from '../../../src/utils'; +import { normalizePath, pathToUrl } from '../../../src/utils'; import { createVirtualTsSystem, getRandomVirtualDirPath } from './test-utils'; -import sinon from 'sinon'; -import { RelativePattern } from 'vscode-languageserver-protocol'; describe('service', () => { const testDir = path.join(__dirname, 'testfiles'); @@ -33,7 +34,8 @@ describe('service', () => { onProjectReloaded: undefined, projectService: undefined, nonRecursiveWatchPattern: undefined, - watchDirectory: undefined + watchDirectory: undefined, + reportConfigError: undefined }; return { virtualSystem, lsDocumentContext, rootUris }; @@ -53,6 +55,11 @@ describe('service', () => { }) ); + virtualSystem.writeFile( + path.join(dirPath, 'random.svelte'), + '' + ); + const ls = await getService( path.join(dirPath, 'random.svelte'), rootUris, @@ -63,11 +70,117 @@ describe('service', () => { delete ls.compilerOptions.configFilePath; assert.deepStrictEqual(ls.compilerOptions, { - allowJs: true, allowNonTsExtensions: true, checkJs: true, strict: true, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Node10, + target: ts.ScriptTarget.ESNext + }); + }); + + it('errors if tsconfig matches no svelte files', async () => { + const dirPath = getRandomVirtualDirPath(testDir); + const { virtualSystem, lsDocumentContext, rootUris } = setup(); + + virtualSystem.readDirectory = () => [path.join(dirPath, 'random.ts')]; + + virtualSystem.writeFile( + path.join(dirPath, 'tsconfig.json'), + JSON.stringify({ + include: ['**/*.ts'] + }) + ); + + virtualSystem.writeFile( + path.join(dirPath, 'random.svelte'), + '' + ); + + let called = false; + await getService(path.join(dirPath, 'random.svelte'), rootUris, { + ...lsDocumentContext, + reportConfigError: (message) => { + called = true; + assert.equal(message.uri, pathToUrl(path.join(dirPath, 'tsconfig.json'))); + } + }); + assert.ok(called); + }); + + it('do not errors if referenced tsconfig matches no svelte files', async () => { + const dirPath = getRandomVirtualDirPath(testDir); + const { virtualSystem, lsDocumentContext, rootUris } = setup(); + + const tsPattern = '**/*.ts'; + const sveltePattern = '**/*.svelte'; + virtualSystem.readDirectory = (_path, _extensions, _excludes, include) => { + return include?.[0] === tsPattern + ? [path.join(dirPath, 'random.ts')] + : include?.[0] === sveltePattern + ? [path.join(dirPath, 'random.svelte')] + : []; + }; + + virtualSystem.writeFile( + path.join(dirPath, 'tsconfig.json'), + JSON.stringify({ + include: [], + references: [{ path: './tsconfig_node.json' }, { path: './tsconfig_web.json' }] + }) + ); + + virtualSystem.writeFile( + path.join(dirPath, 'tsconfig_node.json'), + JSON.stringify({ + include: [tsPattern] + }) + ); + + virtualSystem.writeFile( + path.join(dirPath, 'tsconfig_web.json'), + JSON.stringify({ + include: [sveltePattern] + }) + ); + + virtualSystem.writeFile( + path.join(dirPath, 'random.svelte'), + '' + ); + + let called = false; + const lsContainer = await getService(path.join(dirPath, 'random.svelte'), rootUris, { + ...lsDocumentContext, + reportConfigError: () => { + called = true; + } + }); + + assert.equal( + normalizePath(path.join(dirPath, 'tsconfig_web.json')), + lsContainer.tsconfigPath + ); + assert.equal(called, false, 'expected not to call reportConfigError'); + }); + + it('can loads default tsconfig', async () => { + const dirPath = getRandomVirtualDirPath(testDir); + const { lsDocumentContext, rootUris } = setup(); + + const ls = await getService( + path.join(dirPath, 'random.svelte'), + rootUris, + lsDocumentContext + ); + + assert.deepStrictEqual(ls.compilerOptions, { + allowJs: true, + allowSyntheticDefaultImports: true, + allowNonTsExtensions: true, + configFilePath: undefined, declaration: false, + maxNodeModuleJsDepth: 2, module: ts.ModuleKind.ESNext, moduleResolution: ts.ModuleResolutionKind.Node10, noEmit: true, @@ -93,6 +206,11 @@ describe('service', () => { }) ); + virtualSystem.writeFile( + path.join(dirPath, 'random.svelte'), + '' + ); + const ls = await getService( path.join(dirPath, 'random.svelte'), rootUris, @@ -125,21 +243,24 @@ describe('service', () => { function createReloadTester( docContext: LanguageServiceDocumentContext, - testAfterReload: () => Promise + testAfterReload: (reloadingConfigs: string[]) => Promise ) { let _resolve: () => void; - const reloadPromise = new Promise((resolve) => { + let _reject: (e: unknown) => void; + const reloadPromise = new Promise((resolve, reject) => { _resolve = resolve; + _reject = reject; }); return { docContextWithReload: { ...docContext, - async onProjectReloaded() { + async onProjectReloaded(reloadingConfigs: string[]) { try { - await testAfterReload(); - } finally { + await testAfterReload(reloadingConfigs); _resolve(); + } catch (e) { + _reject(e); } } }, @@ -161,6 +282,11 @@ describe('service', () => { }) ); + virtualSystem.writeFile( + path.join(dirPath, 'random.svelte'), + '' + ); + const { reloadPromise, docContextWithReload } = createReloadTester( { ...lsDocumentContext, watchTsConfig: true }, testAfterReload @@ -189,6 +315,8 @@ describe('service', () => { true, 'expected to reload compilerOptions' ); + + return true; } }); @@ -197,7 +325,7 @@ describe('service', () => { const { virtualSystem, lsDocumentContext, rootUris } = setup(); const tsconfigPath = path.join(dirPath, 'tsconfig.json'); const extend = './.svelte-kit/tsconfig.json'; - const extendedConfigPathFull = path.resolve(tsconfigPath, extend); + const extendedConfigPathFull = path.resolve(path.dirname(tsconfigPath), extend); virtualSystem.writeFile( tsconfigPath, @@ -206,6 +334,11 @@ describe('service', () => { }) ); + virtualSystem.writeFile( + path.join(dirPath, 'random.svelte'), + '' + ); + const { reloadPromise, docContextWithReload } = createReloadTester( { ...lsDocumentContext, watchTsConfig: true }, testAfterReload @@ -234,23 +367,78 @@ describe('service', () => { true, 'expected to reload compilerOptions' ); + return true; } }); - it('can open client file that do not exist in fs', async () => { + it('can watch project reference tsconfig', async () => { const dirPath = getRandomVirtualDirPath(testDir); const { virtualSystem, lsDocumentContext, rootUris } = setup(); + const tsconfigPath = path.join(dirPath, 'tsconfig.json'); + const referenced = './tsconfig_node.json'; + const referencedConfigPathFull = path.resolve(path.dirname(tsconfigPath), referenced); virtualSystem.writeFile( - path.join(dirPath, 'tsconfig.json'), + tsconfigPath, + JSON.stringify({ + references: [{ path: referenced }], + include: [] + }) + ); + + virtualSystem.writeFile( + referencedConfigPathFull, JSON.stringify({ compilerOptions: { - checkJs: true, strict: true + }, + files: ['random.ts'] + }) + ); + + const { reloadPromise, docContextWithReload } = createReloadTester( + { ...lsDocumentContext, watchTsConfig: true }, + testAfterReload + ); + + const tsFilePath = path.join(dirPath, 'random.ts'); + virtualSystem.writeFile(tsFilePath, 'const a: number = null;'); + + const ls = await getService(tsFilePath, rootUris, docContextWithReload); + assert.deepStrictEqual(getSemanticDiagnosticsMessages(ls, tsFilePath), [ + "Type 'null' is not assignable to type 'number'." + ]); + + virtualSystem.writeFile( + referencedConfigPathFull, + JSON.stringify({ + compilerOptions: { + strict: false } }) ); + await reloadPromise; + + async function testAfterReload(reloadingConfigs: string[]) { + if (!reloadingConfigs.includes(referencedConfigPathFull)) { + return false; + } + const newLs = await getService(tsFilePath, rootUris, { + ...lsDocumentContext, + watchTsConfig: true + }); + + assert.deepStrictEqual(getSemanticDiagnosticsMessages(newLs, tsFilePath), []); + return true; + } + }); + + it('can open client file that do not exist in fs', async () => { + const dirPath = getRandomVirtualDirPath(testDir); + const { lsDocumentContext, rootUris } = setup(); + + // don't need tsconfig because files doesn't exist in fs goes to a service with default config const ls = await getService( path.join(dirPath, 'random.svelte'), rootUris, @@ -266,6 +454,60 @@ describe('service', () => { }); }); + it('resolve module with source project reference redirect', async () => { + const dirPath = getRandomVirtualDirPath(testDir); + const { virtualSystem, lsDocumentContext, rootUris } = setup(); + + const package1 = path.join(dirPath, 'package1'); + + virtualSystem.writeFile( + path.join(package1, 'tsconfig.json'), + JSON.stringify({ + references: [{ path: '../package2' }], + files: ['index.ts'] + }) + ); + + const package2 = path.join(dirPath, 'package2'); + virtualSystem.writeFile( + path.join(package2, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + composite: true, + strict: true + }, + files: ['index.ts'] + }) + ); + + const importing = path.join(package1, 'index.ts'); + virtualSystem.writeFile(importing, 'import { hi } from "package2"; hi((a) => `${a}`);'); + + const imported = path.join(package2, 'index.ts'); + virtualSystem.writeFile(imported, 'export function hi(cb: (num: number) => string) {}'); + + const package2Link = normalizePath(path.join(package1, 'node_modules', 'package2')); + virtualSystem.realpath = (p) => { + if (normalizePath(p).startsWith(package2Link)) { + const sub = p.substring(package2Link.length); + return path.join(package2) + sub; + } + + return p; + }; + + const fileExists = virtualSystem.fileExists; + virtualSystem.fileExists = (p) => { + const realPath = virtualSystem.realpath!(p); + + return fileExists(realPath); + }; + + const ls = await getService(importing, rootUris, lsDocumentContext); + + assert.deepStrictEqual(getSemanticDiagnosticsMessages(ls, importing), []); + }); + it('skip directory watching if directory is root', async () => { const dirPath = getRandomVirtualDirPath(path.join(testDir, 'Test')); const { virtualSystem, lsDocumentContext } = setup(); @@ -322,4 +564,11 @@ describe('service', () => { sinon.assert.calledWith(watchDirectory.firstCall, []); }); + + function getSemanticDiagnosticsMessages(ls: LanguageServiceContainer, filePath: string) { + return ls + .getService() + .getSemanticDiagnostics(filePath) + .map((d) => d.messageText); + } }); diff --git a/packages/language-server/test/plugins/typescript/test-utils.ts b/packages/language-server/test/plugins/typescript/test-utils.ts index c77970256..f615f847f 100644 --- a/packages/language-server/test/plugins/typescript/test-utils.ts +++ b/packages/language-server/test/plugins/typescript/test-utils.ts @@ -6,8 +6,14 @@ import { DocumentManager, Document } from '../../../src/lib/documents'; import { FileMap } from '../../../src/lib/documents/fileCollection'; import { LSConfigManager } from '../../../src/ls-config'; import { LSAndTSDocResolver } from '../../../src/plugins'; -import { createGetCanonicalFileName, normalizePath, pathToUrl } from '../../../src/utils'; +import { + createGetCanonicalFileName, + normalizePath, + pathToUrl, + urlToPath +} from '../../../src/utils'; import { VERSION } from 'svelte/compiler'; +import { findTsConfigPath } from '../../../src/plugins/typescript/utils'; const isSvelte5Plus = Number(VERSION.split('.')[0]) >= 5; @@ -100,7 +106,7 @@ export function createVirtualTsSystem(currentDirectory: string): ts.System { ); } - const normalizedPath = normalizePath(toAbsolute(path)); + const normalizedPath = getCanonicalFileName(normalizePath(toAbsolute(path))); return Array.from(virtualFs.keys()).filter((fileName) => fileName.startsWith(normalizedPath) ); @@ -110,6 +116,11 @@ export function createVirtualTsSystem(currentDirectory: string): ts.System { return virtualSystem; function triggerWatch(normalizedPath: string, kind: ts.FileWatcherEventKind) { + // if watcher is not set yet. don't trigger it + if (!watchers.has(normalizedPath)) { + return; + } + let timeoutsOfPath = watchTimeout.get(normalizedPath); if (!timeoutsOfPath) { @@ -273,37 +284,56 @@ export async function createJsonSnapshotFormatter(dir: string) { }); } -export function serviceWarmup(suite: Mocha.Suite, testDir: string, rootUri = pathToUrl(testDir)) { +export function serviceWarmup( + suite: Mocha.Suite, + testDir: string, + rootUri = pathToUrl(testDir), + tsconfigPath: string | undefined = undefined +) { const defaultTimeout = suite.timeout(); // allow to set a higher timeout for slow machines from cli flag const warmupTimeout = Math.max(defaultTimeout, 5_000); suite.timeout(warmupTimeout); - before(async () => { + before(() => warmup(tsconfigPath)); + + suite.timeout(defaultTimeout); + + async function warmup(configFilePath: string | undefined = undefined) { const start = Date.now(); console.log('Warming up language service...'); const docManager = new DocumentManager( (textDocument) => new Document(textDocument.uri, textDocument.text) ); + const lsAndTsDocResolver = new LSAndTSDocResolver( docManager, [rootUri], new LSConfigManager() ); - const filePath = join(testDir, 'DoesNotMater.svelte'); - const document = docManager.openClientDocument({ - uri: pathToUrl(filePath), - text: ts.sys.readFile(filePath) || '' - }); + configFilePath ??= findTsConfigPath( + join(testDir, 'DoesNotMater.svelte'), + [rootUri], + ts.sys.fileExists, + createGetCanonicalFileName(ts.sys.useCaseSensitiveFileNames) + ); - await lsAndTsDocResolver.getLSAndTSDoc(document); + const ls = await lsAndTsDocResolver.getTSServiceByConfigPath( + configFilePath, + configFilePath ? dirname(configFilePath) : urlToPath(rootUri)! + ); + ls.getService(); - console.log(`Service warming up done in ${Date.now() - start}ms`); - }); + const projectReferences = ls.getResolvedProjectReferences(); - suite.timeout(defaultTimeout); + if (projectReferences.length) { + await Promise.all(projectReferences.map((ref) => warmup(ref.configFilePath))); + } + + console.log(`Service warming up done in ${Date.now() - start}ms`); + } } export function recursiveServiceWarmup( diff --git a/packages/language-server/test/plugins/typescript/testfiles/diagnostics/tsconfig.json b/packages/language-server/test/plugins/typescript/testfiles/diagnostics/tsconfig.json index 89dd24236..8cd68407b 100644 --- a/packages/language-server/test/plugins/typescript/testfiles/diagnostics/tsconfig.json +++ b/packages/language-server/test/plugins/typescript/testfiles/diagnostics/tsconfig.json @@ -1,5 +1,7 @@ { "compilerOptions": { + "allowJs": true, + "target": "ESNext", "strict": true, /** This is actually not needed, but makes the tests faster diff --git a/packages/language-server/test/plugins/typescript/testfiles/tsconfig.json b/packages/language-server/test/plugins/typescript/testfiles/tsconfig.json index 63ec98505..03afba4d9 100644 --- a/packages/language-server/test/plugins/typescript/testfiles/tsconfig.json +++ b/packages/language-server/test/plugins/typescript/testfiles/tsconfig.json @@ -1,5 +1,7 @@ { "compilerOptions": { + "allowJs": true, + "target": "ESNext", "strict": true, /** This is actually not needed, but makes the tests faster diff --git a/packages/svelte-check/src/writers.ts b/packages/svelte-check/src/writers.ts index 37c6823b8..af1fd89d8 100644 --- a/packages/svelte-check/src/writers.ts +++ b/packages/svelte-check/src/writers.ts @@ -57,14 +57,9 @@ export class HumanFriendlyWriter implements Writer { `${workspaceDir}${sep}${pc.green(filename)}:${line + 1}:${character + 1}\n` ); - // Show some context around diagnostic range - const codePrevLine = this.getLine(diagnostic.range.start.line - 1, text); - const codeLine = this.getCodeLine(diagnostic, text); - const codeNextLine = this.getLine(diagnostic.range.end.line + 1, text); - const code = codePrevLine + codeLine + codeNextLine; - let msg; if (this.isVerbose) { + const code = this.formatRelatedCode(diagnostic, text); msg = `${diagnostic.message} ${source}\n${pc.cyan(code)}`; } else { msg = `${diagnostic.message} ${source}`; @@ -80,6 +75,20 @@ export class HumanFriendlyWriter implements Writer { }); } + private formatRelatedCode(diagnostic: Diagnostic, text: string) { + if (!text) { + return ''; + } + + // Show some context around diagnostic range + const codePrevLine = this.getLine(diagnostic.range.start.line - 1, text); + const codeLine = this.getCodeLine(diagnostic, text); + const codeNextLine = this.getLine(diagnostic.range.end.line + 1, text); + const code = codePrevLine + codeLine + codeNextLine; + + return code; + } + private getCodeLine(diagnostic: Diagnostic, text: string) { const startOffset = offsetAt(diagnostic.range.start, text); const endOffset = offsetAt(diagnostic.range.end, text); diff --git a/packages/svelte-check/test/tsconfig.json b/packages/svelte-check/test/tsconfig.json index 15458e89c..86649d013 100644 --- a/packages/svelte-check/test/tsconfig.json +++ b/packages/svelte-check/test/tsconfig.json @@ -1,6 +1,6 @@ { - "extends": "@tsconfig/node12/tsconfig.json", "compilerOptions": { + "target": "ESNext", "moduleResolution": "node", "strict": true, "allowJs": true,