diff --git a/Readme.md b/Readme.md index b5f1aa8..62edcbc 100644 --- a/Readme.md +++ b/Readme.md @@ -76,7 +76,7 @@ Almost the same as VSCode. - `tsserver.organizeImports` - `tsserver.watchBuild` - Code completion support. -- Go to definition. +- Go to definition (more info in [microsoft/TypeScript#37777](https://github.com/microsoft/TypeScript/issues/37777)) - Code validation. - Document highlight. - Document symbols of current buffer. @@ -144,6 +144,7 @@ for guide of coc.nvim's configuration. - `typescript.validate.enable`:Enable/disable TypeScript validation., default: `true` - `typescript.showUnused`: show unused variable hint, default: `true`. +- `typescript.autoClosingTags`: Enable/disable autoClosing of JSX tags, default: `false`. - `typescript.suggest.enabled` default: `true` - `typescript.suggest.paths`:Enable/disable suggest paths in import statement and require calls, default: `true` @@ -179,6 +180,7 @@ for guide of coc.nvim's configuration. - `typescript.suggest.includeAutomaticOptionalChainCompletions`: default: `true` - `javascript.format.enabled`: Enable/disable format for javascript files. - `javascript.showUnused`: show unused variable hint. +- `javascript.autoClosingTags`: Enable/disable autoClosing of JSX tags, default: `false`. - `javascript.updateImportsOnFileMove.enable` default: `true` - `javascript.implementationsCodeLens.enable` default: `true` - `javascript.referencesCodeLens.enable` default: `true` diff --git a/package.json b/package.json index 10f462c..9a84a59 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "coc-tsserver", - "version": "1.6.7", + "version": "1.7.0", "description": "tsserver extension for coc.nvim", "main": "lib/index.js", "publisher": "chemzqm", @@ -431,6 +431,10 @@ "default": "allOpenProjects", "scope": "window" }, + "typescript.autoClosingTags": { + "type": "boolean", + "default": false + }, "javascript.showUnused": { "type": "boolean", "default": true, @@ -593,6 +597,10 @@ "description": "%configuration.suggest.includeAutomaticOptionalChainCompletions%", "scope": "resource" }, + "javascript.autoClosingTags": { + "type": "boolean", + "default": false + }, "javascript.format.semicolons": { "type": "string", "default": "ignore", diff --git a/src/server/features/tagClosing.ts b/src/server/features/tagClosing.ts new file mode 100644 index 0000000..b55c127 --- /dev/null +++ b/src/server/features/tagClosing.ts @@ -0,0 +1,201 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { CancellationTokenSource, Disposable, disposeAll, Position, Range, snippetManager, window, workspace } from 'coc.nvim' +import { TextDocumentContentChangeEvent } from 'vscode-languageserver-protocol' +import * as Proto from '../protocol' +import { ITypeScriptServiceClient } from '../typescriptService' +import API from '../utils/api' +import SnippetString from '../utils/SnippetString' +import * as typeConverters from '../utils/typeConverters' + +export default class TagClosing implements Disposable { + public static readonly minVersion = API.v300 + + private static _configurationLanguages: Record = { + 'javascriptreact': 'javascript', + 'typescriptreact': 'typescript', + } + + private _disposables: Disposable[] = [] + private _enabled: boolean = false + private _disposed = false + private _timeout: NodeJS.Timer | undefined = undefined + private _cancel: CancellationTokenSource | undefined = undefined + + constructor( + private readonly client: ITypeScriptServiceClient, + private readonly descriptionLanguageId: string + ) { + workspace.onDidChangeTextDocument( + (event) => + this.onDidChangeTextDocument( + event.textDocument, + event.contentChanges + ), + null, + this._disposables + ) + + this.updateEnabledState() + + workspace.registerAutocmd({ + event: ['BufEnter'], + request: false, + callback: () => this.updateEnabledState(), + }) + } + + async updateEnabledState(): Promise { + this._enabled = false + const doc = await workspace.document + if (!doc) { + return + } + const document = doc.textDocument + const configLang = TagClosing._configurationLanguages[document.languageId] + if (!configLang || configLang !== this.descriptionLanguageId) { + return + } + if (!workspace.getConfiguration(undefined, document.uri).get(`${configLang}.autoClosingTags`)) { + return + } + this._enabled = true + } + + public dispose() { + this._disposed = true + + if (this._timeout) { + clearTimeout(this._timeout) + this._timeout = undefined + } + + if (this._cancel) { + this._cancel.cancel() + this._cancel.dispose() + this._cancel = undefined + } + + disposeAll(this._disposables) + this._disposables = [] + } + + private async onDidChangeTextDocument( + documentEvent: { + uri: string, + version: number, + }, + changes: readonly TextDocumentContentChangeEvent[] + ) { + if (!this._enabled) { + return + } + const document = await workspace.document + if (!document) { + return + } + const activeDocument = document.textDocument + if (activeDocument.uri !== documentEvent.uri || changes.length === 0) { + return + } + const filepath = this.client.toOpenedFilePath(documentEvent.uri) + if (!filepath) { + return + } + + if (typeof this._timeout !== 'undefined') { + clearTimeout(this._timeout) + } + + if (this._cancel) { + this._cancel.cancel() + this._cancel.dispose() + this._cancel = undefined + } + + const lastChange = changes[changes.length - 1] + if (!Range.is(lastChange['range']) || !lastChange.text) { + return + } + + const lastCharacter = lastChange.text[lastChange.text.length - 1] + if (lastCharacter !== '>' && lastCharacter !== '/') { + return + } + + const version = documentEvent.version + + const rangeStart = lastChange['range'].start + const priorCharacter = + lastChange['range'].start.character > 0 + ? activeDocument.getText( + Range.create( + Position.create(rangeStart.line, rangeStart.character - 1), + rangeStart + ) + ) + : '' + if (priorCharacter === '>') { + return + } + + this._timeout = setTimeout(async () => { + this._timeout = undefined + + if (this._disposed) { + return + } + + const addedLines = lastChange.text.split(/\r\n|\n/g) + const position = + addedLines.length <= 1 + ? Position.create( + rangeStart.line, + rangeStart.character + lastChange.text.length + ) + : Position.create( + rangeStart.line + addedLines.length - 1, + addedLines[addedLines.length - 1].length + ) + + const args: Proto.JsxClosingTagRequestArgs = typeConverters.Position.toFileLocationRequestArgs( + filepath, + position + ) + this._cancel = new CancellationTokenSource() + const response = await this.client.execute( + 'jsxClosingTag', + args, + this._cancel.token + ) + if (response.type !== 'response' || !response.body) { + return + } + + if (this._disposed) { + return + } + + const insertion = response.body; + if ( + documentEvent.uri === activeDocument.uri && + activeDocument.version === version + ) { + snippetManager.insertSnippet( + this.getTagSnippet(insertion).value, + false, + Range.create(position, position) + ) + } + }, 100); + } + + private getTagSnippet(closingTag: Proto.TextInsertion): SnippetString { + const snippet = new SnippetString(); + snippet.appendPlaceholder('', 0); + snippet.appendText(closingTag.newText); + return snippet; + } +} diff --git a/src/server/features/tagCompletion.ts b/src/server/features/tagCompletion.ts deleted file mode 100644 index 174e1ef..0000000 --- a/src/server/features/tagCompletion.ts +++ /dev/null @@ -1,54 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -import { TextDocument } from 'coc.nvim' -import { CompletionItemProvider } from 'coc.nvim' -import { CancellationToken, CompletionContext, CompletionItem, Position } from 'vscode-languageserver-protocol' -import * as Proto from '../protocol' -import { ITypeScriptServiceClient } from '../typescriptService' -import * as typeConverters from '../utils/typeConverters' - -export default class TypeScriptTagCompletion implements CompletionItemProvider { - constructor( - private readonly client: ITypeScriptServiceClient - ) {} - - public async provideCompletionItems( - document: TextDocument, - position: Position, - token: CancellationToken, - context: CompletionContext - ): Promise { - const filepath = this.client.toPath(document.uri) - if (!filepath) return undefined - if (context.triggerCharacter != '>') { - return undefined - } - - const args: Proto.JsxClosingTagRequestArgs = typeConverters.Position.toFileLocationRequestArgs(filepath, position) - let body: Proto.TextInsertion | undefined - try { - const response = await this.client.execute('jsxClosingTag', args, token) - body = response && (response as any).body - if (!body) { - return undefined - } - } catch { - return undefined - } - - return [this.getCompletion(body)] - } - - private getCompletion(body: Proto.TextInsertion): CompletionItem { - const completion = CompletionItem.create(body.newText) - completion.insertText = this.getTagSnippet(body) // tslint:disable-line - return completion - } - - private getTagSnippet(closingTag: Proto.TextInsertion): string { - let { newText, caretOffset } = closingTag - return newText.slice(0, caretOffset) + '$0' + newText.slice(caretOffset) - } -} diff --git a/src/server/languageProvider.ts b/src/server/languageProvider.ts index 58a75b9..e867d91 100644 --- a/src/server/languageProvider.ts +++ b/src/server/languageProvider.ts @@ -2,9 +2,9 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { disposeAll, languages, Uri, workspace } from 'coc.nvim' +import { disposeAll, languages, TextDocument, Uri, workspace } from 'coc.nvim' import path from 'path' -import { CodeActionKind, Diagnostic, DiagnosticSeverity, Disposable, TextDocument } from 'vscode-languageserver-protocol' +import { CodeActionKind, Diagnostic, DiagnosticSeverity, Disposable } from 'vscode-languageserver-protocol' import { CachedNavTreeResponse } from './features/baseCodeLensProvider' import CompletionItemProvider from './features/completionItemProvider' import DefinitionProvider from './features/definitionProvider' @@ -19,7 +19,6 @@ import HoverProvider from './features/hover' import ImplementationsCodeLensProvider from './features/implementationsCodeLens' import ImportfixProvider from './features/importFix' import InstallModuleProvider from './features/moduleInstall' -// import TagCompletionProvider from './features/tagCompletion' import QuickfixProvider from './features/quickfix' import RefactorProvider from './features/refactor' import ReferenceProvider from './features/references' @@ -27,6 +26,7 @@ import ReferencesCodeLensProvider from './features/referencesCodeLens' import RenameProvider from './features/rename' import SignatureHelpProvider from './features/signatureHelp' import SmartSelection from './features/smartSelect' +import TagClosing from './features/tagClosing' import UpdateImportsOnFileRenameHandler from './features/updatePathOnRename' import { OrganizeImportsCodeActionProvider } from './organizeImports' import TypeScriptServiceClient from './typescriptServiceClient' @@ -147,17 +147,9 @@ export default class LanguageProvider { if (this.client.apiVersion.gte(API.v350)) { this._register(languages.registerSelectionRangeProvider(languageIds, new SmartSelection(this.client))) } - // if (this.client.apiVersion.gte(API.v300)) { - // this._register( - // languages.registerCompletionItemProvider( - // `tsserver-${this.description.id}-tag`, - // 'TSC', - // languageIds, - // new TagCompletionProvider(client), - // ['>'] - // ) - // ) - // } + if (this.client.apiVersion.gte(API.v300)) { + this._register(new TagClosing(this.client, this.description.id)) + } } public handles(resource: string, doc: TextDocument): boolean { diff --git a/src/server/typescriptServiceClient.ts b/src/server/typescriptServiceClient.ts index 97eaf48..301d7f4 100644 --- a/src/server/typescriptServiceClient.ts +++ b/src/server/typescriptServiceClient.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import cp from 'child_process' -import { ServiceStat, Uri, window, workspace } from 'coc.nvim' +import { Document, ServiceStat, Uri, window, workspace } from 'coc.nvim' import fs from 'fs' import os from 'os' import path from 'path' @@ -491,7 +491,7 @@ export default class TypeScriptServiceClient implements ITypeScriptServiceClient public toResource(filepath: string): string { if (this._apiVersion.gte(API.v213)) { - if (filepath.startsWith('untitled:')) { + if (filepath.startsWith(this.inMemoryResourcePrefix + 'untitled:')) { let resource = Uri.parse(filepath) if (this.inMemoryResourcePrefix) { const dirName = path.dirname(resource.path) @@ -524,14 +524,22 @@ export default class TypeScriptServiceClient implements ITypeScriptServiceClient } } + public getDocument(resource: string): Document | undefined { + if (resource.startsWith('untitled:')) { + let bufnr = parseInt(resource.split(':', 2)[1], 10) + return workspace.getDocument(bufnr) + } + return workspace.getDocument(resource) + } + private get inMemoryResourcePrefix(): string { return this._apiVersion.gte(API.v270) ? '^' : '' } public asUrl(filepath: string): Uri { if (this._apiVersion.gte(API.v213)) { - if (filepath.startsWith('untitled:')) { - let resource = Uri.parse(filepath) + if (filepath.startsWith(this.inMemoryResourcePrefix + 'untitled:')) { + let resource = Uri.parse(filepath.slice(this.inMemoryResourcePrefix.length)) if (this.inMemoryResourcePrefix) { const dirName = path.dirname(resource.path) const fileName = path.basename(resource.path) diff --git a/src/server/typescriptServiceClientHost.ts b/src/server/typescriptServiceClientHost.ts index 2f62757..5f1479a 100644 --- a/src/server/typescriptServiceClientHost.ts +++ b/src/server/typescriptServiceClientHost.ts @@ -181,7 +181,7 @@ export default class TypeScriptServiceClientHost implements Disposable { public async findLanguage(uri: string): Promise { try { - let doc = await workspace.loadFile(uri) + let doc = this.client.getDocument(uri) if (!doc) return undefined let languages = Array.from(this.languagePerId.values()) return languages.find(language => language.handles(uri, doc.textDocument))