From c4ae2c2fbf9c4caca4632f5dd4d35cabfc99a72c Mon Sep 17 00:00:00 2001 From: Qiming Zhao Date: Mon, 7 Dec 2020 18:57:53 +0800 Subject: [PATCH] rework bufferSyncSupport --- src/server/commands.ts | 11 +- src/server/features/bufferSyncSupport.ts | 749 +++++++++++------- src/server/features/completionItemProvider.ts | 1 + src/server/features/diagnostics.ts | 2 + .../features/fileConfigurationManager.ts | 31 +- src/server/features/formatting.ts | 6 +- .../features/implementationsCodeLens.ts | 2 +- src/server/features/projectError.ts | 50 -- src/server/features/quickfix.ts | 17 +- src/server/features/refactor.ts | 2 +- src/server/features/referencesCodeLens.ts | 4 +- src/server/features/rename.ts | 7 +- src/server/features/resourceMap.ts | 16 +- src/server/features/updatePathOnRename.ts | 2 +- src/server/index.ts | 25 +- src/server/languageProvider.ts | 35 +- src/server/organizeImports.ts | 13 +- src/server/typescriptService.ts | 25 +- src/server/typescriptServiceClient.ts | 121 ++- src/server/typescriptServiceClientHost.ts | 48 +- src/server/utils/languageDescription.ts | 7 +- src/server/utils/languageModeIds.ts | 20 + src/utils/plugins.ts | 1 + 23 files changed, 735 insertions(+), 460 deletions(-) delete mode 100644 src/server/features/projectError.ts diff --git a/src/server/commands.ts b/src/server/commands.ts index 3f3c825..fe55060 100644 --- a/src/server/commands.ts +++ b/src/server/commands.ts @@ -7,7 +7,6 @@ import { TextEdit, Range } from 'vscode-languageserver-types' import { installModules } from './utils/modules' import { nodeModules } from './utils/helper' import { PluginManager } from '../utils/plugins' -import { languageIds } from './utils/languageModeIds' export interface Command { readonly id: string | string[] @@ -48,12 +47,11 @@ export class TypeScriptGoToProjectConfigCommand implements Command { public async execute(): Promise { let doc = await workspace.document - let { filetype } = doc - if (languageIds.indexOf(filetype) == -1) { - workspace.showMessage(`Could not determine TypeScript or JavaScript project. Unsupported file type: ${filetype}`, 'warning') + let { languageId } = doc.textDocument + if (this.client.serviceClient.modeIds.indexOf(languageId) == -1) { + workspace.showMessage(`Could not determine TypeScript or JavaScript project. Unsupported file type: ${languageId}`, 'warning') return } - // doc.filetype await goToProjectConfig(this.client, doc.uri) } } @@ -100,7 +98,8 @@ export class AutoFixCommand implements Command { public async execute(): Promise { let document = await workspace.document let { uri } = document - if (!this.client.handles(uri)) { + let handles = await this.client.handles(uri) + if (!handles) { workspace.showMessage(`Document ${uri} is not handled by tsserver.`, 'warning') return } diff --git a/src/server/features/bufferSyncSupport.ts b/src/server/features/bufferSyncSupport.ts index 2f622bd..75528c1 100644 --- a/src/server/features/bufferSyncSupport.ts +++ b/src/server/features/bufferSyncSupport.ts @@ -3,33 +3,146 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { Uri, disposeAll, workspace } from 'coc.nvim' -import { CancellationTokenSource, Emitter, Event, DidChangeTextDocumentParams, Disposable, TextDocumentContentChangeEvent } from 'vscode-languageserver-protocol' +import { CancellationTokenSource, CancellationToken, Emitter, Event, DidChangeTextDocumentParams, Disposable, TextDocumentContentChangeEvent } from 'vscode-languageserver-protocol' import { TextDocument } from 'vscode-languageserver-textdocument' import Proto from '../protocol' import { ITypeScriptServiceClient } from '../typescriptService' import API from '../utils/api' import { Delayer } from '../utils/async' import * as typeConverters from '../utils/typeConverters' -import * as languageModeIds from '../utils/languageModeIds' +import { mode2ScriptKind } from '../utils/languageModeIds' +import { ResourceMap } from './resourceMap' -function mode2ScriptKind( - mode: string -): 'TS' | 'TSX' | 'JS' | 'JSX' | undefined { - switch (mode) { - case languageModeIds.typescript: - return 'TS' - case languageModeIds.typescripttsx: - return 'TSX' - case languageModeIds.typescriptjsx: - return 'TSX' - case languageModeIds.typescriptreact: - return 'TSX' - case languageModeIds.javascript: - return 'JS' - case languageModeIds.javascriptreact: - return 'JSX' +const enum BufferKind { + TypeScript = 1, + JavaScript = 2, +} + +const enum BufferState { + Initial = 1, + Open = 2, + Closed = 2, +} + +const enum BufferOperationType { Close, Open, Change } + +class CloseOperation { + readonly type = BufferOperationType.Close; + constructor( + public readonly args: string + ) { } +} + +class OpenOperation { + readonly type = BufferOperationType.Open; + constructor( + public readonly args: Proto.OpenRequestArgs + ) { } +} + +class ChangeOperation { + readonly type = BufferOperationType.Change; + constructor( + public readonly args: Proto.FileCodeEdits + ) { } +} + +type BufferOperation = CloseOperation | OpenOperation | ChangeOperation + + +class SyncedBuffer { + + private state = BufferState.Initial; + + constructor( + private readonly document: TextDocument, + public readonly filepath: string, + private readonly client: ITypeScriptServiceClient, + private readonly synchronizer: BufferSynchronizer, + ) { } + + public open(): void { + const args: Proto.OpenRequestArgs = { + file: this.filepath, + fileContent: this.document.getText(), + projectRootPath: this.client.getProjectRootPath(this.document.uri), + } + const scriptKind = mode2ScriptKind(this.document.languageId) + if (scriptKind) { + args.scriptKindName = scriptKind + } + + if (this.client.apiVersion.gte(API.v240)) { + // plugin managed. + const tsPluginsForDocument = this.client.pluginManager.plugins + .filter(x => x.languages.indexOf(this.document.languageId) >= 0) + if (tsPluginsForDocument.length) { + (args as any).plugins = tsPluginsForDocument.map(plugin => plugin.name) + } + } + + this.synchronizer.open(this.resource, args) + this.state = BufferState.Open + } + + public get resource(): string { + return this.document.uri + } + + public get lineCount(): number { + return this.document.lineCount + } + + public get kind(): BufferKind { + if (this.document.languageId.startsWith('javascript')) { + return BufferKind.JavaScript + } + return BufferKind.TypeScript + } + + /** + * @return Was the buffer open? + */ + public close(): boolean { + if (this.state !== BufferState.Open) { + this.state = BufferState.Closed + return false + } + this.state = BufferState.Closed + return this.synchronizer.close(this.resource, this.filepath) + } + + public onContentChanged(events: readonly TextDocumentContentChangeEvent[]): void { + if (this.state !== BufferState.Open) { + console.error(`Unexpected buffer state: ${this.state}`) + } + this.synchronizer.change(this.resource, this.filepath, events) + } +} + +class SyncedBufferMap extends ResourceMap { + + public getForPath(filePath: string): SyncedBuffer | undefined { + return this.get(Uri.file(filePath).toString()) + } + + public get allBuffers(): Iterable { + return this.values + } +} + +class PendingDiagnostics extends ResourceMap { + public getOrderedFileSet(): ResourceMap { + const orderedResources = Array.from(this.entries) + .sort((a, b) => a.value - b.value) + .map(entry => entry.resource) + + const map = new ResourceMap(this._normalizePath) + for (const resource of orderedResources) { + map.set(resource, undefined) + } + return map } - return undefined } /** @@ -39,52 +152,64 @@ function mode2ScriptKind( */ class BufferSynchronizer { - private _pending: Proto.UpdateOpenRequestArgs = {} - private _pendingFiles = new Set() + private readonly _pending: ResourceMap constructor( - private readonly client: ITypeScriptServiceClient - ) { } - - public open(args: Proto.OpenRequestArgs): void { - this.client.executeWithoutWaitingForResponse('open', args) + private readonly client: ITypeScriptServiceClient, + pathNormalizer: (path: string) => string | undefined + ) { + this._pending = new ResourceMap(pathNormalizer) } - public close(filepath: string): void { - const args: Proto.FileRequestArgs = { file: filepath } - this.client.executeWithoutWaitingForResponse('close', args) + public open(resource: string, args: Proto.OpenRequestArgs) { + if (this.supportsBatching) { + this.updatePending(resource, new OpenOperation(args)) + } else { + this.client.executeWithoutWaitingForResponse('open', args) + } } - public change(filepath: string, events: TextDocumentContentChangeEvent[]): void { + /** + * @return Was the buffer open? + */ + public close(resource: string, filepath: string): boolean { + if (this.supportsBatching) { + return this.updatePending(resource, new CloseOperation(filepath)) + } else { + const args: Proto.FileRequestArgs = { file: filepath } + this.client.executeWithoutWaitingForResponse('close', args) + return true + } + } + + public change(resource: string, filepath: string, events: readonly TextDocumentContentChangeEvent[]) { if (!events.length) { return } - if (this.supportsBatching) { - this.updatePending(filepath, pending => { - if (!pending.changedFiles) { - pending.changedFiles = [] - } - pending.changedFiles.push({ - fileName: filepath, - textChanges: events.map((change): Proto.CodeEdit => ({ - newText: change.text, - start: typeConverters.Position.toLocation((change as any).range.start), - end: typeConverters.Position.toLocation((change as any).range.end), - })).reverse(), // Send the edits end-of-document to start-of-document order - }) - }) + this.updatePending(resource, new ChangeOperation({ + fileName: filepath, + textChanges: events.map((change): Proto.CodeEdit => ({ + newText: change.text, + start: typeConverters.Position.toLocation((change as any).range.start), + end: typeConverters.Position.toLocation((change as any).range.end), + })).reverse(), // Send the edits end-of-document to start-of-document order + })) } else { - for (const event of events) { + for (const { range, text } of events as any) { const args: Proto.ChangeRequestArgs = { - insertString: event.text, - ...typeConverters.Range.toFormattingRequestArgs(filepath, (event as any).range) + insertString: text, + ...typeConverters.Range.toFormattingRequestArgs(filepath, range) } this.client.executeWithoutWaitingForResponse('change', args) } } } + public reset(): void { + this._pending.clear() + } + public beforeCommand(command: string): void { if (command === 'updateOpen') { return @@ -93,265 +218,51 @@ class BufferSynchronizer { this.flush() } - private flush(): void { + private flush() { if (!this.supportsBatching) { // We've already eagerly synchronized + this._pending.clear() return } - if (this._pending.changedFiles) { - this.client.executeWithoutWaitingForResponse('updateOpen', this._pending) - this._pending = {} - this._pendingFiles.clear() + if (this._pending.size > 0) { + const closedFiles: string[] = [] + const openFiles: Proto.OpenRequestArgs[] = [] + const changedFiles: Proto.FileCodeEdits[] = [] + for (const change of this._pending.values) { + switch (change.type) { + case BufferOperationType.Change: changedFiles.push(change.args); break + case BufferOperationType.Open: openFiles.push(change.args); break + case BufferOperationType.Close: closedFiles.push(change.args); break + } + } + this.client.execute('updateOpen', { changedFiles, closedFiles, openFiles }, CancellationToken.None, { nonRecoverable: true }) + this._pending.clear() } } private get supportsBatching(): boolean { - return this.client.apiVersion.gte(API.v340) && workspace.getConfiguration('tsserver').get('useBatchedBufferSync', true) + return this.client.apiVersion.gte(API.v340) } - private updatePending(filepath: string, f: (pending: Proto.UpdateOpenRequestArgs) => void): void { - if (this.supportsBatching && this._pendingFiles.has(filepath)) { - this.flush() - this._pendingFiles.clear() - f(this._pending) - this._pendingFiles.add(filepath) - } else { - f(this._pending) - } - } - - public reset(): void { - this._pending = {} - this._pendingFiles.clear() - } -} - -export default class BufferSyncSupport { - private readonly client: ITypeScriptServiceClient - - private readonly modeIds: Set - private readonly uris: Set = new Set() - private readonly disposables: Disposable[] = [] - - private readonly pendingDiagnostics = new Map() - private readonly diagnosticDelayer: Delayer - private pendingGetErr: GetErrRequest | undefined - private readonly synchronizer: BufferSynchronizer - private _validateJavaScript = true - private _validateTypeScript = true - - private listening = false - private readonly _onDelete = new Emitter() - public readonly onDelete: Event = this._onDelete.event - - constructor( - client: ITypeScriptServiceClient, - ) { - this.client = client - this.synchronizer = new BufferSynchronizer(client) - this.modeIds = new Set(languageModeIds.languageIds) - this.diagnosticDelayer = new Delayer(300) - } - - public listen(): void { - if (this.listening) { - return - } - this.listening = true - workspace.onDidOpenTextDocument( - this.onDidOpenTextDocument, - this, - this.disposables - ) - workspace.onDidCloseTextDocument( - this.onDidCloseTextDocument, - this, - this.disposables - ) - workspace.onDidChangeTextDocument( - this.onDidChangeTextDocument, - this, - this.disposables - ) - workspace.textDocuments.forEach(this.onDidOpenTextDocument, this) - this.updateConfiguration() - workspace.onDidChangeConfiguration(this.updateConfiguration, this, this.disposables) - } - - public dispose(): void { - this.pendingDiagnostics.clear() - disposeAll(this.disposables) - } - - public onDidOpenTextDocument(document: TextDocument): void { - if (!this.modeIds.has(document.languageId)) return - let { uri } = document - let filepath = this.client.toPath(uri) - this.uris.add(uri) - const args: Proto.OpenRequestArgs = { - file: filepath, - fileContent: document.getText() - } - - if (this.client.apiVersion.gte(API.v203)) { - const scriptKind = mode2ScriptKind(document.languageId) - if (scriptKind) { - args.scriptKindName = scriptKind - } - } - if (this.client.apiVersion.gte(API.v230)) { - let root = this.client.getProjectRootPath(document.uri) - if (root) args.projectRootPath = root - } - this.synchronizer.open(args) - // this.client.executeWithoutWaitingForResponse('open', args) - this.requestDiagnostic(uri) - } - - private onDidCloseTextDocument(document: TextDocument): void { - let { uri } = document - if (!this.uris.has(uri)) return - let filepath = this.client.toPath(uri) - this.uris.delete(uri) - this.pendingDiagnostics.delete(uri) - this.synchronizer.close(filepath) - this._onDelete.fire(uri) - this.requestAllDiagnostics() - // this.client.executeWithoutWaitingForResponse('close', args) - } - - private onDidChangeTextDocument(e: DidChangeTextDocumentParams): void { - let { textDocument, contentChanges } = e - let { uri } = textDocument - if (!this.uris.has(uri)) return - let filepath = this.client.toPath(uri) - this.synchronizer.change(filepath, contentChanges) - const didTrigger = this.requestDiagnostic(uri) - if (!didTrigger && this.pendingGetErr) { - // In this case we always want to re-trigger all diagnostics - this.pendingGetErr.cancel() - this.pendingGetErr = undefined - this.triggerDiagnostics() - } - } - - public beforeCommand(command: string): void { - this.synchronizer.beforeCommand(command) - } - - public interuptGetErr(f: () => R): R { - if (!this.pendingGetErr) { - return f() - } - - this.pendingGetErr.cancel() - this.pendingGetErr = undefined - const result = f() - this.triggerDiagnostics() - return result - } - - public getErr(resources: Uri[]): any { - const handledResources = resources.filter(resource => this.uris.has(resource.toString())) - if (!handledResources.length) { - return - } - - for (const resource of handledResources) { - let uri = resource.toString() - if (this.shouldValidate(uri)) { - this.pendingDiagnostics.set(uri, Date.now()) - } - } - - this.triggerDiagnostics() - } - - public has(uri: string): boolean { - return this.uris.has(uri) - } - - private triggerDiagnostics(delay = 200): void { - this.diagnosticDelayer.trigger(() => { - this.sendPendingDiagnostics() - }, delay) - } - - public requestAllDiagnostics(): void { - for (const uri of this.uris) { - if (this.shouldValidate(uri)) { - this.pendingDiagnostics.set(uri, Date.now()) - } - } - this.diagnosticDelayer.trigger(() => { // tslint:disable-line - this.sendPendingDiagnostics() - }, 200) - } - - public requestDiagnostic(uri: string): boolean { - let document = workspace.getDocument(uri) - if (!document || !this.shouldValidate(uri)) return false - this.pendingDiagnostics.set(uri, Date.now()) - const lineCount = document.lineCount - const delay = Math.min(Math.max(Math.ceil(lineCount / 20), 300), 800) - this.triggerDiagnostics(delay) - return true - } - - public hasPendingDiagnostics(uri: string): boolean { - return this.pendingDiagnostics.has(uri) - } - - private sendPendingDiagnostics(): void { - const uris = Array.from(this.pendingDiagnostics.entries()) - .sort((a, b) => a[1] - b[1]) - .map(entry => entry[0]) - - // Add all open TS buffers to the geterr request. They might be visible - for (const uri of this.uris) { - if (uris.indexOf(uri) == -1) { - uris.push(uri) - } - } - let files = uris.map(uri => this.client.toPath(uri)) - if (files.length) { - if (this.pendingGetErr) this.pendingGetErr.cancel() - const getErr = this.pendingGetErr = GetErrRequest.executeGetErrRequest(this.client, files, () => { - if (this.pendingGetErr === getErr) { - this.pendingGetErr = undefined + private updatePending(resource: string, op: BufferOperation): boolean { + switch (op.type) { + case BufferOperationType.Close: + const existing = this._pending.get(resource) + switch (existing?.type) { + case BufferOperationType.Open: + this._pending.delete(resource) + return false // Open then close. No need to do anything } - }) + break } - this.pendingDiagnostics.clear() - } - private updateConfiguration(): void { - const jsConfig = workspace.getConfiguration('javascript', null) - const tsConfig = workspace.getConfiguration('typescript', null) - this._validateJavaScript = jsConfig.get('validate.enable', true) - this._validateTypeScript = tsConfig.get('validate.enable', true) - } - - public shouldValidate(uri: string): boolean { - let doc = workspace.getDocument(uri) - if (!doc) return false - if (languageModeIds.languageIds.indexOf(doc.filetype) == -1) { - return false - } - if (doc.filetype.startsWith('javascript')) { - return this._validateJavaScript - } - return this._validateTypeScript - } - - public reinitialize(): void { - this.pendingDiagnostics.clear() - this.pendingGetErr?.cancel() - this.synchronizer.reset() - for (let doc of workspace.documents) { - this.onDidOpenTextDocument(doc.textDocument) + if (this._pending.has(resource)) { + // we saw this file before, make sure we flush before working with it again + this.flush() } + this._pending.set(resource, op) + return true } } @@ -397,3 +308,259 @@ class GetErrRequest { this._token.dispose() } } + +export default class BufferSyncSupport { + private disposables: Disposable[] = [] + private readonly client: ITypeScriptServiceClient + + private _validateJavaScript: boolean = true; + private _validateTypeScript: boolean = true; + private readonly modeIds: Set + private readonly syncedBuffers: SyncedBufferMap + private readonly pendingDiagnostics: PendingDiagnostics + private readonly diagnosticDelayer: Delayer + private pendingGetErr: GetErrRequest | undefined + private listening: boolean = false; + private readonly synchronizer: BufferSynchronizer + + private readonly _onDelete = new Emitter() + public readonly onDelete: Event = this._onDelete.event + readonly _onWillChange = new Emitter() + public readonly onWillChange: Event = this._onWillChange.event + + constructor( + client: ITypeScriptServiceClient, + modeIds: readonly string[] + ) { + this.client = client + this.modeIds = new Set(modeIds) + this.diagnosticDelayer = new Delayer(300) + const pathNormalizer = (path: string) => this.client.toPath(path) + this.syncedBuffers = new SyncedBufferMap(pathNormalizer) + this.pendingDiagnostics = new PendingDiagnostics(pathNormalizer) + this.synchronizer = new BufferSynchronizer(client, pathNormalizer) + this.updateConfiguration() + workspace.onDidChangeConfiguration(this.updateConfiguration, this, this.disposables) + } + + public listen(): void { + if (this.listening) { + return + } + this.listening = true + workspace.onDidOpenTextDocument( + this.openTextDocument, + this, + this.disposables + ) + workspace.onDidCloseTextDocument( + this.onDidCloseTextDocument, + this, + this.disposables + ) + workspace.onDidChangeTextDocument( + this.onDidChangeTextDocument, + this, + this.disposables + ) + workspace.textDocuments.forEach(this.openTextDocument, this) + } + + public handles(resource: string): boolean { + return this.syncedBuffers.has(resource) + } + + public dispose(): void { + this.pendingDiagnostics.clear() + disposeAll(this.disposables) + this._onWillChange.dispose() + this._onDelete.dispose() + } + + public ensureHasBuffer(resource: string): boolean { + if (this.syncedBuffers.has(resource)) { + return true + } + + const existingDocument = workspace.textDocuments.find(doc => doc.uri.toString() === resource) + if (existingDocument) { + return this.openTextDocument(existingDocument) + } + return false + } + + public toResource(filePath: string): string { + const buffer = this.syncedBuffers.getForPath(filePath) + if (buffer) return buffer.resource + return Uri.file(filePath).toString() + } + + public reset(): void { + this.pendingGetErr?.cancel() + this.pendingDiagnostics.clear() + this.synchronizer.reset() + } + + public reinitialize(): void { + this.reset() + for (const buffer of this.syncedBuffers.allBuffers) { + buffer.open() + } + } + + public openTextDocument(document: TextDocument): boolean { + if (!this.modeIds.has(document.languageId)) { + // can't handle + return false + } + const resource = document.uri + const filepath = this.client.normalizePath(Uri.parse(resource)) + if (!filepath) { + return false + } + if (this.syncedBuffers.has(resource)) { + return true + } + const syncedBuffer = new SyncedBuffer(document, filepath, this.client, this.synchronizer) + this.syncedBuffers.set(resource, syncedBuffer) + syncedBuffer.open() + this.requestDiagnostic(syncedBuffer) + return true + } + + public closeResource(resource: string): void { + const syncedBuffer = this.syncedBuffers.get(resource) + if (!syncedBuffer) { + return + } + this.pendingDiagnostics.delete(resource) + this.syncedBuffers.delete(resource) + const wasBufferOpen = syncedBuffer.close() + this._onDelete.fire(resource) + if (wasBufferOpen) { + this.requestAllDiagnostics() + } + } + + private onDidCloseTextDocument(document: TextDocument): void { + this.closeResource(document.uri) + } + + private onDidChangeTextDocument(e: DidChangeTextDocumentParams): void { + const syncedBuffer = this.syncedBuffers.get(e.textDocument.uri) + if (!syncedBuffer) { + return + } + this._onWillChange.fire(syncedBuffer.resource) + syncedBuffer.onContentChanged(e.contentChanges) + const didTrigger = this.requestDiagnostic(syncedBuffer) + if (!didTrigger && this.pendingGetErr) { + // In this case we always want to re-trigger all diagnostics + this.pendingGetErr.cancel() + this.pendingGetErr = undefined + this.triggerDiagnostics() + } + } + + public beforeCommand(command: string): void { + this.synchronizer.beforeCommand(command) + } + + public interuptGetErr(f: () => R): R { + if (!this.pendingGetErr) { + return f() + } + + this.pendingGetErr.cancel() + this.pendingGetErr = undefined + const result = f() + this.triggerDiagnostics() + return result + } + + public getErr(resources: Uri[]): any { + const handledResources = resources.filter(resource => this.handles(resource.toString())) + if (!handledResources.length) { + return + } + for (const resource of handledResources) { + this.pendingDiagnostics.set(resource.toString(), Date.now()) + } + this.triggerDiagnostics() + } + + private triggerDiagnostics(delay: number = 200) { + this.diagnosticDelayer.trigger(() => { + this.sendPendingDiagnostics() + }, delay) + } + + public requestAllDiagnostics(): void { + for (const buffer of this.syncedBuffers.allBuffers) { + if (this.shouldValidate(buffer)) { + this.pendingDiagnostics.set(buffer.resource, Date.now()) + } + } + this.triggerDiagnostics() + } + + private requestDiagnostic(buffer: SyncedBuffer): boolean { + if (!this.shouldValidate(buffer)) { + return false + } + this.pendingDiagnostics.set(buffer.resource, Date.now()) + const delay = Math.min(Math.max(Math.ceil(buffer.lineCount / 20), 300), 800) + this.triggerDiagnostics(delay) + return true + } + + public hasPendingDiagnostics(uri: string): boolean { + return this.pendingDiagnostics.has(uri) + } + + private sendPendingDiagnostics(): void { + const orderedFileSet = this.pendingDiagnostics.getOrderedFileSet() + if (this.pendingGetErr) { + this.pendingGetErr.cancel() + for (const file of this.pendingGetErr.files) { + let resource = Uri.file(file).toString() + if (this.syncedBuffers.get(resource)) { + orderedFileSet.set(resource, undefined) + } + } + this.pendingGetErr = undefined + } + // Add all open TS buffers to the geterr request. They might be visible + for (const buffer of this.syncedBuffers.values) { + orderedFileSet.set(buffer.resource, undefined) + } + if (orderedFileSet.size) { + let files = Array.from(orderedFileSet.keys).map(uri => this.client.normalizePath(Uri.parse(uri))) + const getErr = this.pendingGetErr = GetErrRequest.executeGetErrRequest(this.client, files, () => { + if (this.pendingGetErr === getErr) { + this.pendingGetErr = undefined + } + }) + } + + this.pendingDiagnostics.clear() + } + + private updateConfiguration(): void { + const jsConfig = workspace.getConfiguration('javascript', null) + const tsConfig = workspace.getConfiguration('typescript', null) + this._validateJavaScript = jsConfig.get('validate.enable', true) + this._validateTypeScript = tsConfig.get('validate.enable', true) + } + + private shouldValidate(buffer: SyncedBuffer) { + switch (buffer.kind) { + case BufferKind.JavaScript: + return this._validateJavaScript + + case BufferKind.TypeScript: + default: + return this._validateTypeScript + } + } +} diff --git a/src/server/features/completionItemProvider.ts b/src/server/features/completionItemProvider.ts index 9c06b98..4b54ffd 100644 --- a/src/server/features/completionItemProvider.ts +++ b/src/server/features/completionItemProvider.ts @@ -110,6 +110,7 @@ export default class TypeScriptCompletionItemProvider implements CompletionItemP return null } + await this.client.interruptGetErr(() => this.fileConfigurationManager.ensureConfigurationForDocument(document, token)) const { completeOption } = this const args: Proto.CompletionsRequestArgs & { includeAutomaticOptionalChainCompletions?: boolean } = { ...typeConverters.Position.toFileLocationRequestArgs(file, position), diff --git a/src/server/features/diagnostics.ts b/src/server/features/diagnostics.ts index 57c63ca..8654c88 100644 --- a/src/server/features/diagnostics.ts +++ b/src/server/features/diagnostics.ts @@ -84,6 +84,8 @@ export class DiagnosticsManager { ): void { const collection = this._diagnostics.get(kind) if (!collection) return + let doc = workspace.getDocument(uri) + if (doc) uri = doc.uri if (diagnostics.length === 0) { const existing = collection.get(uri) diff --git a/src/server/features/fileConfigurationManager.ts b/src/server/features/fileConfigurationManager.ts index 6cd6d87..e3c0420 100644 --- a/src/server/features/fileConfigurationManager.ts +++ b/src/server/features/fileConfigurationManager.ts @@ -2,8 +2,8 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { workspace, WorkspaceConfiguration } from 'coc.nvim' -import { CancellationToken } from 'vscode-languageserver-protocol' +import { workspace, WorkspaceConfiguration, disposeAll } from 'coc.nvim' +import { CancellationToken, Disposable } from 'vscode-languageserver-protocol' import { TextDocument } from 'vscode-languageserver-textdocument' import Proto from '../protocol' import { ITypeScriptServiceClient } from '../typescriptService' @@ -41,11 +41,20 @@ export interface SuggestOptions { export default class FileConfigurationManager { private cachedMap: Map = new Map() + private disposables: Disposable[] = [] public constructor(private readonly client: ITypeScriptServiceClient) { + workspace.onDidCloseTextDocument(textDocument => { + // When a document gets closed delete the cached formatting options. + // This is necessary since the tsserver now closed a project when its + // last file in it closes which drops the stored formatting options + // as well. + this.cachedMap.delete(textDocument.uri) + }, undefined, this.disposables) + } - public async ensureConfigurationOptions(document: TextDocument, insertSpaces: boolean, tabSize: number): Promise { + public async ensureConfigurationOptions(document: TextDocument, insertSpaces: boolean, tabSize: number, token: CancellationToken): Promise { const file = this.client.toPath(document.uri) let options: FormatOptions = { tabSize, @@ -62,11 +71,19 @@ export default class FileConfigurationManager { ...currentOptions } await this.client.execute('configure', args, CancellationToken.None) + try { + const response = await this.client.execute('configure', args, token) + if (response.type !== 'response') { + this.cachedMap.delete(document.uri) + } + } catch (_e) { + this.cachedMap.delete(document.uri) + } } - public async ensureConfigurationForDocument(document: TextDocument): Promise { + public async ensureConfigurationForDocument(document: TextDocument, token: CancellationToken): Promise { let opts = await workspace.getFormatOptions(document.uri) - return this.ensureConfigurationOptions(document, opts.insertSpaces, opts.tabSize) + return this.ensureConfigurationOptions(document, opts.insertSpaces, opts.tabSize, token) } public reset(): void { @@ -164,6 +181,10 @@ export default class FileConfigurationManager { if (this.client.apiVersion.gte(API.v333) || quoteStyle != 'auto') return quoteStyle return 'single' } + + public dispose(): void { + disposeAll(this.disposables) + } } type ModuleImportType = 'relative' | 'non-relative' | 'auto' diff --git a/src/server/features/formatting.ts b/src/server/features/formatting.ts index d99a51d..d5365fe 100644 --- a/src/server/features/formatting.ts +++ b/src/server/features/formatting.ts @@ -35,7 +35,8 @@ export default class TypeScriptFormattingProvider await this.formattingOptionsManager.ensureConfigurationOptions( document, options.insertSpaces, - options.tabSize + options.tabSize, + token ) try { const response = await this.client.execute('format', args, token) @@ -101,7 +102,8 @@ export default class TypeScriptFormattingProvider await this.formattingOptionsManager.ensureConfigurationOptions( document, options.insertSpaces, - options.tabSize + options.tabSize, + token ) const doc = workspace.getDocument(document.uri) diff --git a/src/server/features/implementationsCodeLens.ts b/src/server/features/implementationsCodeLens.ts index 42210d1..8d6333e 100644 --- a/src/server/features/implementationsCodeLens.ts +++ b/src/server/features/implementationsCodeLens.ts @@ -22,7 +22,7 @@ export default class TypeScriptImplementationsCodeLensProvider extends TypeScrip codeLens.range.start ) try { - const response = await this.client.execute('implementation', args, token, true) + const response = await this.client.execute('implementation', args, token, { lowPriority: true }) if (response && response.type == 'response' && response.body) { const locations = response.body .map(reference => { diff --git a/src/server/features/projectError.ts b/src/server/features/projectError.ts deleted file mode 100644 index 1f74032..0000000 --- a/src/server/features/projectError.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { disposeAll, workspace } from 'coc.nvim' -import { Command, CommandManager } from 'coc.nvim/lib/commands' -import { Disposable, CancellationToken } from 'vscode-languageserver-protocol' -import * as Proto from '../protocol' -import { ITypeScriptServiceClient } from '../typescriptService' -import * as languageIds from '../utils/languageModeIds' - -class ProjectErrorCommand implements Command { - public readonly id: string = 'tsserver.project_error' - - constructor( - private readonly client: ITypeScriptServiceClient - ) { - } - - public async execute(): Promise { - let document = await workspace.document - if (languageIds[document.filetype] == null) return - let file = this.client.toPath(document.uri) - const args: Proto.GeterrForProjectRequestArgs = { - file, - delay: 20 - } - const response = null - // await this.client.execute('geterrForProject', args, CancellationToken.None) - if (!response || !response.success) { - return - } - - return - } -} - -export default class ProjectErrors { - private disposables: Disposable[] = [] - public constructor( - client: ITypeScriptServiceClient, - commandManager: CommandManager - ) { - let cmd = new ProjectErrorCommand(client) - commandManager.register(cmd) - this.disposables.push(Disposable.create(() => { - commandManager.unregister(cmd.id) - })) - } - - public dispose(): void { - disposeAll(this.disposables) - } -} diff --git a/src/server/features/quickfix.ts b/src/server/features/quickfix.ts index 7b4d7d8..123e460 100644 --- a/src/server/features/quickfix.ts +++ b/src/server/features/quickfix.ts @@ -12,6 +12,7 @@ import { ITypeScriptServiceClient } from '../typescriptService' import API from '../utils/api' import { applyCodeActionCommands, getEditForCodeAction } from '../utils/codeAction' import * as typeConverters from '../utils/typeConverters' +import FileConfigurationManager from './fileConfigurationManager' class ApplyCodeActionCommand implements Command { public static readonly ID = '_typescript.applyCodeActionCommand' @@ -19,6 +20,7 @@ class ApplyCodeActionCommand implements Command { constructor( private readonly client: ITypeScriptServiceClient, + private readonly formattingConfigurationManager: FileConfigurationManager ) { } public async execute(action: Proto.CodeFixAction): Promise { @@ -32,15 +34,18 @@ class ApplyFixAllCodeAction implements Command { constructor( private readonly client: ITypeScriptServiceClient, + private readonly formattingConfigurationManager: FileConfigurationManager ) { } public async execute( + document: TextDocument, file: string, tsAction: Proto.CodeFixAction ): Promise { if (!tsAction.fixId) { return } + await this.formattingConfigurationManager.ensureConfigurationForDocument(document, CancellationToken.None) const args: Proto.GetCombinedCodeFixRequestArgs = { scope: { @@ -141,12 +146,13 @@ export default class TypeScriptQuickFixProvider implements CodeActionProvider { constructor( private readonly client: ITypeScriptServiceClient, + private readonly formattingConfigurationManager: FileConfigurationManager ) { commands.register( - new ApplyCodeActionCommand(client) + new ApplyCodeActionCommand(client, formattingConfigurationManager) ) commands.register( - new ApplyFixAllCodeAction(client) + new ApplyFixAllCodeAction(client, formattingConfigurationManager) ) this.supportedCodeActionProvider = new SupportedCodeActionProvider(client) @@ -158,14 +164,11 @@ export default class TypeScriptQuickFixProvider implements CodeActionProvider { context: CodeActionContext, token: CancellationToken ): Promise { - if (!this.client.apiVersion.gte(API.v213)) { - return [] - } - const file = this.client.toPath(document.uri) if (!file) { return [] } + await this.formattingConfigurationManager.ensureConfigurationForDocument(document, token) const fixableDiagnostics = await this.supportedCodeActionProvider.getFixableDiagnosticsForContext( context @@ -283,7 +286,7 @@ export default class TypeScriptQuickFixProvider implements CodeActionProvider { action.diagnostics = [diagnostic] action.command = { command: ApplyFixAllCodeAction.ID, - arguments: [file, tsAction], + arguments: [document, file, tsAction], title: '' } return action diff --git a/src/server/features/refactor.ts b/src/server/features/refactor.ts index 3371b7f..4bdc36a 100644 --- a/src/server/features/refactor.ts +++ b/src/server/features/refactor.ts @@ -127,7 +127,7 @@ export default class TypeScriptRefactorProvider implements CodeActionProvider { } const file = this.client.toPath(document.uri) if (!file) return undefined - await this.formattingOptionsManager.ensureConfigurationForDocument(document) + await this.formattingOptionsManager.ensureConfigurationForDocument(document, token) const args: Proto.GetApplicableRefactorsRequestArgs = typeConverters.Range.toFileRangeRequestArgs( file, range diff --git a/src/server/features/referencesCodeLens.ts b/src/server/features/referencesCodeLens.ts index 4b56a7f..6284f9a 100644 --- a/src/server/features/referencesCodeLens.ts +++ b/src/server/features/referencesCodeLens.ts @@ -21,7 +21,9 @@ export default class TypeScriptReferencesCodeLensProvider extends TypeScriptBase codeLens.range.start ) return this.client - .execute('references', args, token, true) + .execute('references', args, token, { + lowPriority: true + }) .then(response => { if (!response || response.type != 'response' || !response.body) { throw codeLens diff --git a/src/server/features/rename.ts b/src/server/features/rename.ts index 6f5d384..a4e5b75 100644 --- a/src/server/features/rename.ts +++ b/src/server/features/rename.ts @@ -10,9 +10,13 @@ import * as Proto from '../protocol' import { ITypeScriptServiceClient, ServerResponse } from '../typescriptService' import API from '../utils/api' import * as typeConverters from '../utils/typeConverters' +import FileConfigurationManager from './fileConfigurationManager' export default class TypeScriptRenameProvider implements RenameProvider { - public constructor(private readonly client: ITypeScriptServiceClient) { } + public constructor( + private readonly client: ITypeScriptServiceClient, + private readonly fileConfigurationManager: FileConfigurationManager + ) { } public async prepareRename( document: TextDocument, @@ -81,6 +85,7 @@ export default class TypeScriptRenameProvider implements RenameProvider { findInStrings: false, findInComments: false } + await this.fileConfigurationManager.ensureConfigurationForDocument(document, token) return this.client.interruptGetErr(() => { return this.client.execute('rename', args, token) diff --git a/src/server/features/resourceMap.ts b/src/server/features/resourceMap.ts index ad71fb3..565201f 100644 --- a/src/server/features/resourceMap.ts +++ b/src/server/features/resourceMap.ts @@ -13,9 +13,19 @@ export class ResourceMap { private readonly _map = new Map() constructor( - private readonly _normalizePath?: (resource: string) => string | null + protected readonly _normalizePath?: (resource: string) => string | null ) { } + public get size() { + return this._map.size + } + + public get entries(): { resource: string, value: T }[] { + return Array.from(this._map.keys()).map(key => { + return { resource: key, value: this._map[key] } + }) + } + public has(resource: string): boolean { const file = this.toKey(resource) return !!file && this._map.has(file) @@ -48,6 +58,10 @@ export class ResourceMap { return this._map.keys() } + public clear(): void { + this._map.clear() + } + private toKey(resource: string): string | null { const key = this._normalizePath ? this._normalizePath(resource) diff --git a/src/server/features/updatePathOnRename.ts b/src/server/features/updatePathOnRename.ts index 1e91d65..64a0fda 100644 --- a/src/server/features/updatePathOnRename.ts +++ b/src/server/features/updatePathOnRename.ts @@ -85,7 +85,7 @@ export default class UpdateImportsOnFileRenameHandler { } private async getEditsForFileRename(document: TextDocument, oldFile: string, newFile: string): Promise { - await this.fileConfigurationManager.ensureConfigurationForDocument(document) + await this.fileConfigurationManager.ensureConfigurationForDocument(document, CancellationToken.None) const response = await this.client.interruptGetErr(() => { const args: Proto.GetEditsForFileRenameRequestArgs = { oldFilePath: oldFile, diff --git a/src/server/index.ts b/src/server/index.ts index c7ac259..143336b 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,9 +1,8 @@ -import { Uri, disposeAll, IServiceProvider, ServiceStat, workspace, WorkspaceConfiguration } from 'coc.nvim' +import { disposeAll, IServiceProvider, ServiceStat, workspace, WorkspaceConfiguration } from 'coc.nvim' import { Disposable, DocumentSelector, Emitter, Event } from 'vscode-languageserver-protocol' +import { PluginManager } from '../utils/plugins' import TypeScriptServiceClientHost from './typescriptServiceClientHost' import { LanguageDescription, standardLanguageDescriptions } from './utils/languageDescription' -import { PluginManager } from '../utils/plugins' -import { TextDocument } from 'vscode-languageserver-textdocument' export default class TsserverService implements IServiceProvider { public id = 'tsserver' @@ -28,24 +27,12 @@ export default class TsserverService implements IServiceProvider { this.selector = this.descriptions.reduce((arr, c) => { return arr.concat(c.modeIds) }, []) - workspace.onDidOpenTextDocument(doc => { - this.ensureConfigurationForDocument(doc) - }, null, this.disposables) } public get config(): WorkspaceConfiguration { return workspace.getConfiguration('tsserver') } - public ensureConfigurationForDocument(document: TextDocument): void { - let uri = Uri.parse(document.uri) - let language = this.clientHost.findLanguage(uri) - if (!language) return - language.fileConfigurationManager.ensureConfigurationForDocument(document).catch(_e => { - // noop - }) - } - public start(): Promise { if (this.clientHost) return this.state = ServiceStat.Starting @@ -61,7 +48,6 @@ export default class TsserverService implements IServiceProvider { } }) this._onDidServiceReady.fire(void 0) - this.ensureConfiguration() if (!started) { started = true resolve() @@ -70,13 +56,6 @@ export default class TsserverService implements IServiceProvider { }) } - private ensureConfiguration(): void { - if (!this.clientHost) return - for (let doc of workspace.documents) { - this.ensureConfigurationForDocument(doc.textDocument) - } - } - public dispose(): void { disposeAll(this.disposables) } diff --git a/src/server/languageProvider.ts b/src/server/languageProvider.ts index b9bb3e4..5f6bde9 100644 --- a/src/server/languageProvider.ts +++ b/src/server/languageProvider.ts @@ -3,7 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { commands, DiagnosticKind, disposeAll, languages, Uri, workspace } from 'coc.nvim' -import { CodeActionKind, Diagnostic, DiagnosticSeverity, Disposable } from 'vscode-languageserver-protocol' +import path from 'path' +import { CodeActionKind, Diagnostic, DiagnosticSeverity, Disposable, TextDocument } from 'vscode-languageserver-protocol' import { CachedNavTreeResponse } from './features/baseCodeLensProvider' import CompletionItemProvider from './features/completionItemProvider' import DefinitionProvider from './features/definitionProvider' @@ -37,16 +38,14 @@ import TypingsStatus from './utils/typingsStatus' const suggestionSetting = 'suggestionActions.enabled' export default class LanguageProvider { - public readonly fileConfigurationManager: FileConfigurationManager // tslint:disable-line private readonly disposables: Disposable[] = [] constructor( public client: TypeScriptServiceClient, + private readonly fileConfigurationManager: FileConfigurationManager, private description: LanguageDescription, - typingsStatus: TypingsStatus + private typingsStatus: TypingsStatus ) { - this.fileConfigurationManager = new FileConfigurationManager(client) - workspace.onDidChangeConfiguration(this.configurationChanged, this, this.disposables) this.configurationChanged() let initialized = false @@ -167,7 +166,7 @@ export default class LanguageProvider { this.disposables.push( languages.registerRenameProvider( languageIds, - new RenameProvider(client)) + new RenameProvider(client, this.fileConfigurationManager)) ) let formatProvider = new FormattingProvider(client, this.fileConfigurationManager) this.disposables.push( @@ -224,7 +223,7 @@ export default class LanguageProvider { this.disposables.push( languages.registerCodeActionProvider( languageIds, - new QuickfixProvider(client), + new QuickfixProvider(client, this.fileConfigurationManager), 'tsserver', [CodeActionKind.QuickFix])) @@ -276,23 +275,12 @@ export default class LanguageProvider { // } } - public handles(resource: Uri): boolean { - let { modeIds, configFile } = this.description - if (resource.toString().endsWith(configFile)) { + public handles(resource: string, doc: TextDocument): boolean { + if (doc && this.description.modeIds.indexOf(doc.languageId) >= 0) { return true } - let doc = workspace.getDocument(resource.toString()) - if (doc && modeIds.indexOf(doc.filetype) !== -1) { - return true - } - let str = resource.toString() - if (this.id === 'typescript' && /\.ts(x)?$/.test(str)) { - return true - } - if (this.id === 'javascript' && /\.js(x)?$/.test(str)) { - return true - } - return false + const base = path.basename(Uri.parse(resource).fsPath) + return !!base && (!!this.description.configFilePattern && this.description.configFilePattern.test(base)) } private get id(): string { // tslint:disable-line @@ -312,9 +300,6 @@ export default class LanguageProvider { file: Uri, diagnostics: (Diagnostic & { reportUnnecessary: any })[] ): void { - if (!this.client.bufferSyncSupport.shouldValidate(file.toString())) { - return - } const config = workspace.getConfiguration(this.id, file.toString()) const reportUnnecessary = config.get('showUnused', true) this.client.diagnosticsManager.diagnosticsReceived(diagnosticsKind, file.toString(), diagnostics.filter(diag => { diff --git a/src/server/organizeImports.ts b/src/server/organizeImports.ts index ffeb2bf..68b418a 100644 --- a/src/server/organizeImports.ts +++ b/src/server/organizeImports.ts @@ -8,7 +8,6 @@ import { TextDocument } from 'vscode-languageserver-textdocument' import { Command } from './commands' import Proto from './protocol' import { standardLanguageDescriptions } from './utils/languageDescription' -import { languageIds } from './utils/languageModeIds' import * as typeconverts from './utils/typeConverters' import FileConfigurationManager from './features/fileConfigurationManager' import TypeScriptServiceClient from './typescriptServiceClient' @@ -49,7 +48,8 @@ export class OrganizeImportsCommand implements Command { public async execute(document?: TextDocument): Promise { if (!document) { let doc = await workspace.document - if (languageIds.indexOf(doc.filetype) == -1) return + if (!doc.attached) return + if (this.client.modeIds.indexOf(doc.textDocument.languageId) == -1) return document = doc.textDocument } let edit = await this.getTextEdits(document) @@ -71,17 +71,18 @@ export class OrganizeImportsCodeActionProvider implements CodeActionProvider { providedCodeActionKinds: [CodeActionKind.SourceOrganizeImports] } - public provideCodeActions( + public async provideCodeActions( document: TextDocument, _range: Range, context: CodeActionContext, - _token: CancellationToken - ): CodeAction[] { - if (languageIds.indexOf(document.languageId) == -1) return + token: CancellationToken + ): Promise { + if (this.client.modeIds.indexOf(document.languageId) == -1) return if (!context.only || !context.only.includes(CodeActionKind.SourceOrganizeImports)) { return [] } + await this.fileConfigManager.ensureConfigurationForDocument(document, token) const action = CodeAction.create('Organize Imports', { title: '', diff --git a/src/server/typescriptService.ts b/src/server/typescriptService.ts index fbb7792..1dc60eb 100644 --- a/src/server/typescriptService.ts +++ b/src/server/typescriptService.ts @@ -10,6 +10,7 @@ import { TypeScriptServiceConfiguration } from './utils/configuration' import Logger from './utils/logger' import BufferSyncSupport from './features/bufferSyncSupport' import { DiagnosticsManager } from './features/diagnostics' +import { PluginManager } from '../utils/plugins' export namespace ServerResponse { @@ -33,10 +34,23 @@ export interface TypeScriptServerPlugin { readonly languages: string[] } +export enum ExectuionTarget { + Semantic, + Syntax +} + +export type ExecConfig = { + readonly lowPriority?: boolean + readonly nonRecoverable?: boolean + readonly cancelOnResourceChange?: string + readonly executionTarget?: ExectuionTarget +} + export interface TypeScriptRequestTypes { 'applyCodeActionCommand': [Proto.ApplyCodeActionCommandRequestArgs, Proto.ApplyCodeActionCommandResponse] 'completionEntryDetails': [Proto.CompletionDetailsRequestArgs, Proto.CompletionDetailsResponse] 'completionInfo': [Proto.CompletionsRequestArgs, Proto.CompletionInfoResponse] + 'updateOpen': [Proto.UpdateOpenRequestArgs, Proto.Response] // tslint:disable-next-line: deprecation 'completions': [Proto.CompletionsRequestArgs, Proto.CompletionsResponse] 'configure': [Proto.ConfigureRequestArguments, Proto.ConfigureResponse] @@ -79,6 +93,7 @@ export interface ITypeScriptServiceClient { readonly logger: Logger readonly bufferSyncSupport: BufferSyncSupport readonly diagnosticsManager: DiagnosticsManager + readonly pluginManager: PluginManager getProjectRootPath(uri: string): string | null normalizePath(resource: Uri): string | null @@ -90,7 +105,7 @@ export interface ITypeScriptServiceClient { command: K, args: TypeScriptRequestTypes[K][0], token: CancellationToken, - lowPriority?: boolean + config?: ExecConfig ): Promise> executeWithoutWaitingForResponse(command: 'open', args: Proto.OpenRequestArgs): void @@ -107,4 +122,12 @@ export interface ITypeScriptServiceClient { * Cancel on going geterr requests and re-queue them after `f` has been evaluated. */ interruptGetErr(f: () => R): R + /** + * Tries to ensure that a vscode document is open on the TS server. + * + * @return The normalized path or `undefined` if the document is not open on the server. + */ + toOpenedFilePath(uri: string, options?: { + suppressAlertOnFailure?: boolean + }): string | undefined } diff --git a/src/server/typescriptServiceClient.ts b/src/server/typescriptServiceClient.ts index 62770a4..5031549 100644 --- a/src/server/typescriptServiceClient.ts +++ b/src/server/typescriptServiceClient.ts @@ -7,7 +7,7 @@ import { disposeAll, ServiceStat, Uri, workspace } from 'coc.nvim' import fs from 'fs' import os from 'os' import path from 'path' -import { CancellationToken, Disposable, Emitter, Event } from 'vscode-languageserver-protocol' +import { CancellationToken, CancellationTokenSource, Disposable, Emitter, Event } from 'vscode-languageserver-protocol' import { PluginManager } from '../utils/plugins' import { CallbackMap } from './callbackMap' import BufferSyncSupport from './features/bufferSyncSupport' @@ -15,7 +15,7 @@ import { DiagnosticKind, DiagnosticsManager } from './features/diagnostics' import FileConfigurationManager from './features/fileConfigurationManager' import * as Proto from './protocol' import { RequestItem, RequestQueue, RequestQueueingType } from './requestQueue' -import { ITypeScriptServiceClient, ServerResponse } from './typescriptService' +import { ExecConfig, ITypeScriptServiceClient, ServerResponse } from './typescriptService' import API from './utils/api' import { TsServerLogLevel, TypeScriptServiceConfiguration } from './utils/configuration' import Logger from './utils/logger' @@ -26,9 +26,16 @@ import { TypeScriptVersion, TypeScriptVersionProvider } from './utils/versionPro import VersionStatus from './utils/versionStatus' import { ICallback, Reader } from './utils/wireProtocol' +interface ToCancelOnResourceChanged { + readonly resource: string + cancel(): void +} + class ForkedTsServerProcess { constructor(private childProcess: cp.ChildProcess) { } + public readonly toCancelOnResourceChange = new Set() + public onError(cb: (err: Error) => void): void { this.childProcess.on('error', cb) } @@ -75,6 +82,7 @@ export default class TypeScriptServiceClient implements ITypeScriptServiceClient private _configuration: TypeScriptServiceConfiguration private versionProvider: TypeScriptVersionProvider private tsServerLogFile: string | null = null + private tsServerProcess: ForkedTsServerProcess | undefined private servicePromise: Thenable | null private lastError: Error | null private lastStart: number @@ -97,7 +105,10 @@ export default class TypeScriptServiceClient implements ITypeScriptServiceClient private readonly disposables: Disposable[] = [] private isRestarting = false - constructor(private pluginManager: PluginManager) { + constructor( + public readonly pluginManager: PluginManager, + public readonly modeIds: string[] + ) { this.pathSeparator = path.sep this.lastStart = Date.now() this.servicePromise = null @@ -117,15 +128,19 @@ export default class TypeScriptServiceClient implements ITypeScriptServiceClient this.restartTsServer() }, null, this.disposables) - this.bufferSyncSupport = new BufferSyncSupport(this) + this.bufferSyncSupport = new BufferSyncSupport(this, modeIds) this.onTsServerStarted(() => { this.bufferSyncSupport.listen() }) this.diagnosticsManager = new DiagnosticsManager() this.bufferSyncSupport.onDelete(resource => { + this.cancelInflightRequestsForResource(resource) this.diagnosticsManager.delete(resource) }, null, this.disposables) + this.bufferSyncSupport.onWillChange(resource => { + this.cancelInflightRequestsForResource(resource) + }) } private _onDiagnosticsReceived = new Emitter() @@ -313,6 +328,7 @@ export default class TypeScriptServiceClient implements ITypeScriptServiceClient this.state = ServiceStat.Running this.info('Started TSServer', JSON.stringify(currentVersion, null, 2)) const handle = new ForkedTsServerProcess(childProcess) + this.tsServerProcess = handle this.lastStart = Date.now() handle.onError((err: Error) => { @@ -383,6 +399,7 @@ export default class TypeScriptServiceClient implements ITypeScriptServiceClient } private serviceStarted(resendModels: boolean): void { + this.bufferSyncSupport.reset() const watchOptions = this.apiVersion.gte(API.v380) ? this.configuration.watchOptions : undefined @@ -398,6 +415,7 @@ export default class TypeScriptServiceClient implements ITypeScriptServiceClient this.setCompilerOptionsForInferredProjects(this._configuration) if (resendModels) { this._onResendModelsRequested.fire(void 0) + this.fileConfigurationManager.reset() this.diagnosticsManager.reInitialize() this.bufferSyncSupport.reinitialize() } @@ -461,6 +479,16 @@ export default class TypeScriptServiceClient implements ITypeScriptServiceClient return this.normalizePath(Uri.parse(uri)) } + public toOpenedFilePath(uri: string, options: { suppressAlertOnFailure?: boolean } = {}): string | undefined { + if (!this.bufferSyncSupport.ensureHasBuffer(uri)) { + if (!options.suppressAlertOnFailure) { + console.error(`Unexpected resource ${uri}`) + } + return undefined + } + return this.toPath(uri) + } + public toResource(filepath: string): string { if (this._apiVersion.gte(API.v213)) { if (filepath.startsWith('untitled:')) { @@ -525,13 +553,52 @@ export default class TypeScriptServiceClient implements ITypeScriptServiceClient public execute( command: string, args: any, token: CancellationToken, - lowPriority?: boolean): Promise> { - return this.executeImpl(command, args, { - isAsync: false, - token, - expectsResult: true, - lowPriority - }) + config?: ExecConfig + ): Promise> { + let execution: Promise> + + if (config?.cancelOnResourceChange) { + const source = new CancellationTokenSource() + token.onCancellationRequested(() => source.cancel()) + const inFlight: ToCancelOnResourceChanged = { + resource: config.cancelOnResourceChange, + cancel: () => source.cancel(), + } + this.tsServerProcess?.toCancelOnResourceChange.add(inFlight) + + execution = this.executeImpl(command, args, { + isAsync: false, + token: source.token, + expectsResult: true, + ...config, + }).finally(() => { + this.tsServerProcess?.toCancelOnResourceChange.delete(inFlight) + source.dispose() + }) + } else { + execution = this.executeImpl(command, args, { + isAsync: false, + token, + expectsResult: true, + ...config, + }) + } + + if (config?.nonRecoverable) { + execution.catch(err => this.fatalError(command, err)) + } + return execution + } + + private fatalError(command: string, error: any): void { + console.error(`A non-recoverable error occured while executing tsserver command: ${command}`) + + if (this.state === ServiceStat.Running) { + this.info('Killing TS Server by fatal error:', error) + this.service().then(service => { + service.kill() + }) + } } public executeAsync( @@ -860,21 +927,20 @@ export default class TypeScriptServiceClient implements ITypeScriptServiceClient return args } - public getProjectRootPath(uri: string): string | null { + public getProjectRootPath(uri: string): string | undefined { let root = workspace.cwd let u = Uri.parse(uri) - if (u.scheme == 'file') { - let folder = workspace.getWorkspaceFolder(uri) - if (folder) { - root = Uri.parse(folder.uri).fsPath - } else { - let filepath = Uri.parse(uri).fsPath - if (!filepath.startsWith(root)) { - root = path.dirname(filepath) - } + if (u.scheme !== 'file') return undefined + let folder = workspace.getWorkspaceFolder(uri) + if (folder) { + root = Uri.parse(folder.uri).fsPath + } else { + let filepath = Uri.parse(uri).fsPath + if (!filepath.startsWith(root)) { + root = path.dirname(filepath) } } - if (root == os.homedir()) return null + if (root == os.homedir()) return undefined return root } @@ -891,6 +957,17 @@ export default class TypeScriptServiceClient implements ITypeScriptServiceClient public interruptGetErr(f: () => R): R { return this.bufferSyncSupport.interuptGetErr(f) } + + private cancelInflightRequestsForResource(resource: string): void { + if (this.state !== ServiceStat.Running || !this.tsServerProcess) { + return + } + for (const request of this.tsServerProcess.toCancelOnResourceChange) { + if (request.resource.toString() === resource.toString()) { + request.cancel() + } + } + } } function getDiagnosticsKind(event: Proto.Event): DiagnosticKind { diff --git a/src/server/typescriptServiceClientHost.ts b/src/server/typescriptServiceClientHost.ts index 22333d7..0e5cbf0 100644 --- a/src/server/typescriptServiceClientHost.ts +++ b/src/server/typescriptServiceClientHost.ts @@ -7,11 +7,13 @@ import { Range, Diagnostic, DiagnosticSeverity, Disposable, Position, Cancellati import LanguageProvider from './languageProvider' import * as Proto from './protocol' import * as PConst from './protocol.const' +import FileConfigurationManager from './features/fileConfigurationManager' import TypeScriptServiceClient from './typescriptServiceClient' import { LanguageDescription } from './utils/languageDescription' import * as typeConverters from './utils/typeConverters' import TypingsStatus, { AtaProgressReporter } from './utils/typingsStatus' import { PluginManager } from '../utils/plugins' +import { flatten } from '../utils/arrays' // Style check diagnostics that can be reported as warnings const styleCheckDiagnostics = [ @@ -30,6 +32,7 @@ export default class TypeScriptServiceClientHost implements Disposable { private readonly languages: LanguageProvider[] = [] private readonly languagePerId = new Map() private readonly disposables: Disposable[] = [] + private readonly fileConfigurationManager: FileConfigurationManager private reportStyleCheckAsWarnings = true constructor(descriptions: LanguageDescription[], pluginManager: PluginManager) { @@ -50,10 +53,13 @@ export default class TypeScriptServiceClientHost implements Disposable { packageFileWatcher.onDidCreate(this.reloadProjects, this, this.disposables) packageFileWatcher.onDidChange(handleProjectChange, this, this.disposables) - this.client = new TypeScriptServiceClient(pluginManager) + const allModeIds = this.getAllModeIds(descriptions, pluginManager) + this.client = new TypeScriptServiceClient(pluginManager, allModeIds) this.disposables.push(this.client) this.client.onDiagnosticsReceived(({ kind, resource, diagnostics }) => { - this.diagnosticsReceived(kind, resource, diagnostics) + this.diagnosticsReceived(kind, resource, diagnostics).catch(e => { + console.error(e) + }) }, null, this.disposables) this.client.onConfigDiagnosticsReceived(diag => { @@ -79,12 +85,13 @@ export default class TypeScriptServiceClientHost implements Disposable { } } }, null, this.disposables) - this.typingsStatus = new TypingsStatus(this.client) this.ataProgressReporter = new AtaProgressReporter(this.client) + this.fileConfigurationManager = new FileConfigurationManager(this.client) for (const description of descriptions) { // tslint:disable-line const manager = new LanguageProvider( this.client, + this.fileConfigurationManager, description, this.typingsStatus ) @@ -103,14 +110,13 @@ export default class TypeScriptServiceClientHost implements Disposable { public dispose(): void { disposeAll(this.disposables) + this.fileConfigurationManager.dispose() this.typingsStatus.dispose() this.ataProgressReporter.dispose() } public reset(): void { - for (let lang of this.languages) { - lang.fileConfigurationManager.reset() - } + this.fileConfigurationManager.reset() } public get serviceClient(): TypeScriptServiceClient { @@ -132,16 +138,22 @@ export default class TypeScriptServiceClientHost implements Disposable { this.reportStyleCheckAsWarnings = config.get('reportStyleChecksAsWarnings', true) } - public findLanguage(resource: Uri): LanguageProvider | null { + public async findLanguage(uri: string): Promise { try { - return this.languages.find(language => language.handles(resource)) + let doc = await workspace.loadFile(uri) + if (!doc) return undefined + return this.languages.find(language => language.handles(uri, doc.textDocument)) } catch { - return null + return undefined } } - public handles(uri: string): boolean { - return this.findLanguage(Uri.parse(uri)) != null + public async handles(uri: string): Promise { + const provider = await this.findLanguage(uri) + if (provider) { + return true + } + return this.client.bufferSyncSupport.handles(uri) } private triggerAllDiagnostics(): void { @@ -150,12 +162,12 @@ export default class TypeScriptServiceClientHost implements Disposable { } } - private diagnosticsReceived( + private async diagnosticsReceived( kind: DiagnosticKind, resource: Uri, diagnostics: Proto.Diagnostic[] - ): void { - const language = this.findLanguage(resource) + ): Promise { + const language = await this.findLanguage(resource.toString()) if (language) { language.diagnosticsReceived( kind, @@ -222,4 +234,12 @@ export default class TypeScriptServiceClientHost implements Disposable { private isStyleCheckDiagnostic(code: number | undefined): boolean { return code ? styleCheckDiagnostics.indexOf(code) !== -1 : false } + + private getAllModeIds(descriptions: LanguageDescription[], pluginManager: PluginManager) { + const allModeIds = flatten([ + ...descriptions.map(x => x.modeIds), + ...pluginManager.plugins.map(x => x.languages) + ]) + return allModeIds + } } diff --git a/src/server/utils/languageDescription.ts b/src/server/utils/languageDescription.ts index 2605862..75cd1f9 100644 --- a/src/server/utils/languageDescription.ts +++ b/src/server/utils/languageDescription.ts @@ -12,6 +12,7 @@ export interface LanguageDescription { readonly configFile?: string readonly isExternal?: boolean readonly diagnosticOwner: string + readonly configFilePattern?: RegExp } export const enum DiagnosticLanguage { @@ -27,7 +28,8 @@ export const standardLanguageDescriptions: LanguageDescription[] = [ modeIds: [languageModeIds.typescript, languageModeIds.typescriptreact, languageModeIds.typescripttsx, languageModeIds.typescriptjsx], diagnosticLanguage: DiagnosticLanguage.TypeScript, - configFile: 'tsconfig.json' + configFile: 'tsconfig.json', + configFilePattern: /^tsconfig(\..*)?\.json$/gi }, { id: 'javascript', @@ -35,6 +37,7 @@ export const standardLanguageDescriptions: LanguageDescription[] = [ diagnosticOwner: 'typescript', modeIds: [languageModeIds.javascript, languageModeIds.javascriptreact, languageModeIds.javascriptjsx], diagnosticLanguage: DiagnosticLanguage.JavaScript, - configFile: 'jsconfig.json' + configFile: 'jsconfig.json', + configFilePattern: /^jsconfig(\..*)?\.json$/gi } ] diff --git a/src/server/utils/languageModeIds.ts b/src/server/utils/languageModeIds.ts index ce4cfcb..2b5e9a5 100644 --- a/src/server/utils/languageModeIds.ts +++ b/src/server/utils/languageModeIds.ts @@ -13,3 +13,23 @@ export const javascriptjsx = 'javascript.jsx' export const jsxTags = 'jsx-tags' export const languageIds = [typescript, typescriptreact, javascript, javascriptreact, javascriptjsx, typescripttsx, jsxTags] + +export function mode2ScriptKind( + mode: string +): 'TS' | 'TSX' | 'JS' | 'JSX' | undefined { + switch (mode) { + case typescript: + return 'TS' + case typescripttsx: + return 'TSX' + case typescriptjsx: + return 'TSX' + case typescriptreact: + return 'TSX' + case javascript: + return 'JS' + case javascriptreact: + return 'JSX' + } + return undefined +} diff --git a/src/utils/plugins.ts b/src/utils/plugins.ts index d39a99e..e57e526 100644 --- a/src/utils/plugins.ts +++ b/src/utils/plugins.ts @@ -12,6 +12,7 @@ export interface TypeScriptServerPlugin { readonly name: string readonly enableForWorkspaceTypeScriptVersions: boolean readonly languages: ReadonlyArray + readonly configNamespace?: string } namespace TypeScriptServerPlugin {