diff --git a/package.json b/package.json index dd06d08..673429f 100644 --- a/package.json +++ b/package.json @@ -421,7 +421,7 @@ "@types/fast-diff": "^1.2.0", "@types/find-up": "^2.1.1", "@types/node": "^10.12.18", - "coc.nvim": "0.0.39", + "coc.nvim": "0.0.52", "rimraf": "^2.6.2", "tslint": "^5.12.0" }, diff --git a/src/server/commands.ts b/src/server/commands.ts index a56236a..2d6bc11 100644 --- a/src/server/commands.ts +++ b/src/server/commands.ts @@ -5,6 +5,7 @@ import * as Proto from './protocol' import TypeScriptServiceClientHost from './typescriptServiceClientHost' import * as typeConverters from './utils/typeConverters' import { TextEdit, Range } from 'vscode-languageserver-types' +import { installModules } from './utils/modules' const nodeModules = [ 'assert', @@ -130,11 +131,21 @@ export class AutoFixCommand implements Command { } let file = this.client.serviceClient.toPath(document.uri) let diagnostics = diagnosticManager.getDiagnostics(document.uri) - diagnostics = diagnostics.filter(x => autoFixableDiagnosticCodes.has(x.code as number)) - if (diagnostics.length == 0) { - workspace.showMessage('No autofixable diagnostics found', 'warning') - return + let missingDiagnostics = diagnostics.filter(o => o.code == 2307) + if (missingDiagnostics.length) { + let names = missingDiagnostics.map(o => { + let ms = o.message.match(/module\s'(.+)'\./) + return ms ? ms[1] : null + }) + names = names.filter(s => s != null) + if (names.length) { + installModules(document.uri, names).catch(e => { + console.error(e.message) // tslint:disable-line + }) + } } + diagnostics = diagnostics.filter(x => autoFixableDiagnosticCodes.has(x.code as number)) + if (diagnostics.length == 0) return diagnostics = diagnostics.reduce((arr, curr) => { if (curr.code == 2304 && arr.findIndex(o => o.message == curr.message) != -1) return arr arr.push(curr) diff --git a/src/server/features/moduleInstall.ts b/src/server/features/moduleInstall.ts new file mode 100644 index 0000000..3f593e1 --- /dev/null +++ b/src/server/features/moduleInstall.ts @@ -0,0 +1,56 @@ +import { commands } from 'coc.nvim' +import { Command } from 'coc.nvim/lib/commands' +import { CodeActionProvider } from 'coc.nvim/lib/provider' +import { CancellationToken, CodeAction, CodeActionContext, Range, TextDocument } from 'vscode-languageserver-protocol' +import Uri from 'vscode-uri' +import { ITypeScriptServiceClient } from '../typescriptService' +import { installModules } from '../utils/modules' + +class InstallModuleCommand implements Command { + public static readonly ID = '_tsserver.installModule' + public readonly id = InstallModuleCommand.ID + + public async execute( + uri: string, + name: string + ): Promise { + await installModules(uri, [name]) + } +} + +export default class InstallModuleProvider implements CodeActionProvider { + + constructor(private readonly client: ITypeScriptServiceClient) { + commands.register(new InstallModuleCommand(), true) + } + + public async provideCodeActions( + document: TextDocument, + _range: Range, + context: CodeActionContext, + _token: CancellationToken + ): Promise { + const uri = Uri.parse(document.uri) + if (uri.scheme != 'file') return null + let { diagnostics } = context + let diags = diagnostics.filter(s => s.code == 2307) + let names = diags.map(o => { + let ms = o.message.match(/module\s'(.+)'\./) + return ms ? ms[1] : null + }) + names = names.filter(s => s != null) + if (!names.length) return null + let actions: CodeAction[] = [] + for (let name of names) { + let title = `install ${name}` + let command = { + title: `install ${name}`, + command: InstallModuleCommand.ID, + arguments: [document.uri, name] + } + let codeAction = CodeAction.create(title, command) + actions.push(codeAction) + } + return actions + } +} diff --git a/src/server/languageProvider.ts b/src/server/languageProvider.ts index 0c34d91..e6ee7ff 100644 --- a/src/server/languageProvider.ts +++ b/src/server/languageProvider.ts @@ -29,6 +29,7 @@ import UpdateImportsOnFileRenameHandler from './features/updatePathOnRename' import WatchBuild from './features/watchBuild' import WorkspaceSymbolProvider from './features/workspaceSymbols' import TypeScriptServiceClient from './typescriptServiceClient' +import InstallModuleProvider from './features/moduleInstall' import API from './utils/api' import { LanguageDescription } from './utils/languageDescription' import TypingsStatus from './utils/typingsStatus' @@ -238,6 +239,13 @@ export default class LanguageProvider { [CodeActionKind.Refactor])) } + this.disposables.push( + languages.registerCodeActionProvider( + languageIds, + new InstallModuleProvider(client), + 'tsserver') + ) + this.disposables.push( languages.registerCodeActionProvider( languageIds, diff --git a/src/server/typescriptServiceClientHost.ts b/src/server/typescriptServiceClientHost.ts index 09267ca..0b2a9af 100644 --- a/src/server/typescriptServiceClientHost.ts +++ b/src/server/typescriptServiceClientHost.ts @@ -33,25 +33,22 @@ export default class TypeScriptServiceClientHost implements Disposable { private reportStyleCheckAsWarnings = true constructor(descriptions: LanguageDescription[]) { + let timer: NodeJS.Timer const handleProjectChange = () => { - setTimeout(() => { + if (timer) clearTimeout(timer) + timer = setTimeout(() => { this.triggerAllDiagnostics() }, 1500) } const configFileWatcher = workspace.createFileSystemWatcher('**/[tj]sconfig.json') this.disposables.push(configFileWatcher) - configFileWatcher.onDidCreate( - this.reloadProjects, - this, - this.disposables - ) - configFileWatcher.onDidDelete( - this.reloadProjects, - this, - this.disposables - ) + configFileWatcher.onDidCreate(this.reloadProjects, this, this.disposables) + configFileWatcher.onDidDelete(this.reloadProjects, this, this.disposables) configFileWatcher.onDidChange(handleProjectChange, this, this.disposables) + const packageFileWatcher = workspace.createFileSystemWatcher('**/package.json') + packageFileWatcher.onDidCreate(this.reloadProjects, this, this.disposables) + packageFileWatcher.onDidChange(handleProjectChange, this, this.disposables) this.client = new TypeScriptServiceClient() this.disposables.push(this.client) diff --git a/src/server/utils/modules.ts b/src/server/utils/modules.ts new file mode 100644 index 0000000..4453ca2 --- /dev/null +++ b/src/server/utils/modules.ts @@ -0,0 +1,108 @@ +import { exec } from 'child_process' +import path from 'path' +import { workspace } from 'coc.nvim' +import findUp from 'find-up' +import Uri from 'vscode-uri' + +export function runCommand(cmd: string, cwd: string, timeout?: number): Promise { + return new Promise((resolve, reject) => { + let timer: NodeJS.Timer + if (timeout) { + timer = setTimeout(() => { + reject(new Error(`timeout after ${timeout}s`)) + }, timeout * 1000) + } + exec(cmd, { cwd }, (err, stdout) => { + if (timer) clearTimeout(timer) + if (err) { + reject(new Error(`exited with ${err.code}`)) + return + } + resolve(stdout) + }) + }) +} + +function getManager(uri: string): string { + let dir = path.dirname(Uri.parse(uri).fsPath) + let res = findUp.sync(['yarn.lock', 'package-lock.json'], { cwd: dir }) + if (!res) return 'yarn' + return res.endsWith('yarn.lock') ? 'yarn' : 'npm' +} + +function getRoot(uri: string): string { + let dir = path.dirname(Uri.parse(uri).fsPath) + let res = findUp.sync(['package.json'], { cwd: dir }) + if (!res) return dir + return path.dirname(res) +} + +export async function moduleExists(name: string): Promise { + try { + let content = await runCommand(`npm info ${name} --json`, process.cwd()) + if (!content) return false + let obj = JSON.parse(content) + if (obj.error != null) return false + return true + } catch (e) { + return false + } + return false +} + +/** + * Removes duplicates from the given array. The optional keyFn allows to specify + * how elements are checked for equalness by returning a unique string for each. + */ +export function distinct(array: T[], keyFn?: (t: T) => string): T[] { + if (!keyFn) { + return array.filter((element, position) => { + return array.indexOf(element) === position + }) + } + + const seen: { [key: string]: boolean } = Object.create(null) + return array.filter(elem => { + const key = keyFn(elem) + if (seen[key]) { + return false + } + + seen[key] = true + + return true + }) +} + +export async function installModules(uri: string, names: string[]): Promise { + names = distinct(names) + let root = getRoot(uri) + let arr = names.concat(names.map(s => `@types/${s}`)) + let statusItem = workspace.createStatusBarItem(99, { progress: true }) + statusItem.text = `Checking module ${arr.join(' ')}` + statusItem.show() + let exists = await Promise.all(arr.map(name => { + return moduleExists(name).then(exists => { + return exists ? name : null + }) + })) + let manager = getManager(uri) + exists = exists.filter(s => s != null) + if (!exists.length) return + let devs = exists.filter(s => s.startsWith('@types')) + let deps = exists.filter(s => devs.indexOf(s) == -1) + statusItem.text = `Installing ${exists.join(' ')}` + try { + await Promise.all([deps, devs].map((names, i) => { + let cmd = manager == 'npm' ? `npm i ${names.join(' ')}` : `yarn add ${names.join(' ')} --ignore-scripts --no-default-rc` + if (i == 1) cmd = cmd + ' --dev' + return runCommand(cmd, root) + })) + } catch (e) { + statusItem.dispose() + workspace.showMessage(`Install error ${e.message}`, 'error') + return + } + statusItem.dispose() + workspace.showMessage(`Installed: ${exists.join(' ')}`, 'more') +}