├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── LICENSE ├── README.md ├── autoload ├── lspoints.vim └── lspoints │ ├── denops.vim │ ├── internal.vim │ ├── settings.vim │ └── util.vim ├── deno.json ├── denops ├── @ddc-sources │ └── lspoints.ts ├── @lspoints │ ├── format.ts │ └── nvim_diagnostics.ts └── lspoints │ ├── app.ts │ ├── box.ts │ ├── builtin │ ├── configuration.ts │ ├── diagnostics.ts │ └── did_save.ts │ ├── builtins.ts │ ├── client.ts │ ├── deps │ ├── async.ts │ ├── denops.ts │ ├── lsp.ts │ ├── std │ │ ├── async.ts │ │ ├── bytes.ts │ │ ├── deep_merge.ts │ │ ├── path.ts │ │ └── stream.ts │ └── unknownutil.ts │ ├── interface.ts │ ├── jsonrpc │ ├── jsonrpc_client.ts │ ├── jsonrpc_stream.ts │ └── message.ts │ ├── lspoints.ts │ └── version.ts ├── doc ├── config.ts ├── lspoints.jax └── lspoints_example.vim └── lua └── lspoints.lua /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: github-actions 5 | directory: / 6 | schedule: 7 | interval: weekly 8 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | pull_request: 5 | 6 | jobs: 7 | deno-checks: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: denoland/setup-deno@v1 12 | with: 13 | deno-version: v1.x 14 | - name: Check types 15 | run: deno check denops 16 | - name: Lint 17 | run: deno lint denops 18 | - name: Check code formats 19 | run: deno fmt denops --check 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 kuuote and contributors 2 | 3 | This software is provided 'as-is', without any express or implied warranty. 4 | In no event will the authors be held liable for any damages arising from the use of this software. 5 | 6 | Permission is granted to anyone to use this software for any purpose, 7 | including commercial applications, and to alter it and redistribute it 8 | freely, subject to the following restrictions: 9 | 10 | 1. The origin of this software must not be misrepresented; you must not 11 | claim that you wrote the original software. If you use this software 12 | in a product, an acknowledgment in the product documentation would be 13 | appreciated but is not required. 14 | 15 | 2. Altered source versions must be plainly marked as such, and must not be 16 | misrepresented as being the original software. 17 | 18 | 3. This notice may not be removed or altered from any source distribution. 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # lspoints 2 | 3 | Asynchronous LSP Gateway Plugin powered by denops.vim 4 | 5 | 6 | > [!WARNING] 7 | > 8 | > As it is made for study, it may behave weird or break suddenly. 9 | > 10 | 11 | ## Installation 12 | 13 | 14 | You need to install lspoints with [denops](https://github.com/vim-denops/denops.vim) as follows: 15 | 16 | ```vim 17 | Plug 'vim-denops/denops.vim' 18 | Plug 'kuuote/lspoints' 19 | ``` 20 | 21 | > [!IMPORTANT] 22 | > 23 | > As lspoints is a *gateway*, you achive no functionalities without extensions. 24 | > The following graph is the overview of the architecture. 25 | > 26 | 27 | ```mermaid 28 | graph LR 29 | subgraph Lspoints 30 | direction TB 31 | copilot-langauge-server ---|LSP| lspoints 32 | deno_lsp ---|LSP| lspoints 33 | efm-langserver ---|LSP| lspoints 34 | end 35 | lspoints --- lspoints-copilot 36 | lspoints --- ddc-source-lsp 37 | lspoints --- lspoints-hover 38 | lspoints --- nvim_diagnostics 39 | lspoints --- format 40 | format --- Vim/Neovim 41 | nvim_diagnostics --- Vim/Neovim 42 | lspoints-hover --- Vim/Neovim 43 | lspoints-copilot --- Vim/Neovim 44 | ddc-source-lsp --- Vim/Neovim 45 | ``` 46 | 47 | ## Configuration 48 | 49 | To use lspoints, you need to configure two things. 50 | 51 | 1. What *language servers* are available. 52 | 2. What *extensions* are available. 53 | 54 | 55 | ### Language servers 56 | 57 | ```vim 58 | function s:attach_denols() abort 59 | call lspoints#attach('denols', #{ 60 | \ cmd: ['deno', 'lsp'], 61 | \ initializationOptions: #{ 62 | \ enable: v:true, 63 | \ unstable: v:true, 64 | \ suggest: #{ 65 | \ autoImports: v:false, 66 | \ }, 67 | \ }, 68 | \ }) 69 | endfunction 70 | autocmd FileType typescript,typescriptreact call s:attach_denols() 71 | ``` 72 | 73 | ### Extensions 74 | 75 | You need to specify extension to be used. 76 | ```vim 77 | let g:lspoints#extensions = ['nvim_diagnostics', 'format'] 78 | ``` 79 | 80 | Alternatively, you can load extensions dynamically. 81 | ```vim 82 | call lspoints#load_extensions(['nvim_diagnostics', 'format']) 83 | ``` 84 | 85 | ### Configuration example 86 | 87 |
88 | 89 | The example is just a combined version of the above code. 90 | 91 | 92 | ```vim 93 | let g:lspoints#extensions = ['nvim_diagnostics', 'format'] 94 | 95 | function s:attach_denols() abort 96 | call lspoints#attach('denols', #{ 97 | \ cmd: ['deno', 'lsp'], 98 | \ initializationOptions: #{ 99 | \ enable: v:true, 100 | \ unstable: v:true, 101 | \ suggest: #{ 102 | \ autoImports: v:false, 103 | \ }, 104 | \ }, 105 | \ }) 106 | endfunction 107 | autocmd FileType typescript,typescriptreact call s:attach_denols() 108 | ``` 109 | 110 |
111 | 112 | 113 | ## Further readings 114 | 115 | Again, you will not get any functionality without extensions. Please read the documentation of the extensions. 116 | You can find the lspoint extensions at https://github.com/search?q=lspoints&type=repositories . 117 | 118 | 119 | # nvim_diagnostics for lspoints (builtin) 120 | 121 | This is an extension to integrate lspoints into the diagnostic function of Neovim. 122 | 123 | ## Installation 124 | 125 | You need to add `'nvim_diagnostics'` to `g:lspoints#extensions`. 126 | ```vim 127 | let g:lspoints#extensions = ['nvim_diagnostics'] 128 | ``` 129 | 130 | ## Usage 131 | 132 | This extension automatically displays diagnostics in Neovim. You do not need to do anything. 133 | 134 | # format for lspoints (builtin) 135 | 136 | This is an extension to call format function of language servers. 137 | 138 | ## Installation 139 | 140 | You need to add `'format'` to `g:lspoints#extensions`. 141 | ```vim 142 | let g:lspoints#extensions = ['format'] 143 | ``` 144 | 145 | ## Usage 146 | 147 | When you call `denops#request('lspoints', 'executeCommand', ['format', 'execute', bufnr()])`, the language server formats your code. 148 | 149 | ```vim 150 | nnoremap mf call denops#request('lspoints', 'executeCommand', ['format', 'execute', bufnr()]) 151 | ``` 152 | 153 | --- 154 | 155 | © 2024 Kuuote -------------------------------------------------------------------------------- /autoload/lspoints.vim: -------------------------------------------------------------------------------- 1 | let g:lspoints#extensions = get(g:, 'lspoints#extensions', []) 2 | 3 | " async method 4 | 5 | function lspoints#reload() abort 6 | autocmd User DenopsPluginPost:lspoints ++once echo 'lspoints reloaded' 7 | call denops#plugin#reload('lspoints') 8 | endfunction 9 | 10 | function lspoints#start(name, options = {}) abort 11 | call lspoints#denops#register() 12 | call lspoints#denops#notify('start', [a:name, a:options]) 13 | endfunction 14 | 15 | function lspoints#attach(name, options = {}) abort 16 | call lspoints#start(a:name, a:options) 17 | let bufnr = bufnr() 18 | call lspoints#denops#notify('attach', [a:name, bufnr]) 19 | endfunction 20 | 21 | function lspoints#load_extensions(pathes) abort 22 | call extend(g:lspoints#extensions, a:pathes) 23 | if denops#plugin#is_loaded('lspoints') 24 | call lspoints#denops#notify('loadExtensions', [a:pathes]) 25 | endif 26 | endfunction 27 | 28 | function lspoints#notify(name, method, params = {}) abort 29 | call lspoints#denops#notify('notify', [a:name, a:method, a:params]) 30 | endfunction 31 | 32 | function lspoints#request_async(name, method, params, success, failure) abort 33 | call lspoints#denops#request_async('request', [a:name, a:method, a:params], a:success, a:failure) 34 | endfunction 35 | 36 | " sync method 37 | 38 | function lspoints#get_clients(bufnr = bufnr()) abort 39 | return lspoints#denops#request('getClients', [a:bufnr]) 40 | endfunction 41 | 42 | function lspoints#request(name, method, params = {}) abort 43 | return lspoints#denops#request('request', [a:name, a:method, a:params]) 44 | endfunction 45 | -------------------------------------------------------------------------------- /autoload/lspoints/denops.vim: -------------------------------------------------------------------------------- 1 | " lazy load logic based on ddu.vim 2 | 3 | let s:registered = v:false 4 | 5 | const s:root_dir = ''->expand()->fnamemodify(':h:h:h') 6 | const s:sep = has('win32') ? '\' : '/' 7 | function lspoints#denops#register() abort 8 | if s:registered 9 | return 10 | endif 11 | if denops#server#status() !=# 'running' 12 | autocmd User DenopsReady ++once call lspoints#denops#register() 13 | return 14 | endif 15 | let path = [s:root_dir, 'denops', 'lspoints', 'app.ts']->join(s:sep) 16 | try 17 | call denops#plugin#load('lspoints', path) 18 | catch /^Vim\%((\a\+)\)\=:E117:/ 19 | " Fallback to `register` for backward compatibility 20 | call denops#plugin#register('lspoints', path, #{ mode: 'skip' }) 21 | endtry 22 | let s:registered = v:true 23 | endfunction 24 | 25 | function lspoints#denops#notify(method, params) abort 26 | if !s:registered 27 | call lspoints#denops#register() 28 | endif 29 | call denops#plugin#wait_async('lspoints', {->denops#notify('lspoints', a:method, a:params)}) 30 | endfunction 31 | 32 | function lspoints#denops#request(method, params, wait_async = v:false) abort 33 | if a:wait_async 34 | if !s:registered 35 | execute printf( 36 | \ 'autocmd User DenopsPluginPost:lspoints call denops#request("lspoints", "%s", %s)', 37 | \ a:method, 38 | \ string(a:params), 39 | \ ) 40 | else 41 | call denops#plugin#wait_async('lspoints', {->denops#request('lspoints', a:method, a:params)}) 42 | endif 43 | return 44 | endif 45 | if !s:registered 46 | throw 'lspoints is not registered' 47 | endif 48 | call denops#plugin#wait('lspoints') 49 | return denops#request('lspoints', a:method, a:params) 50 | endfunction 51 | 52 | function lspoints#denops#request_async(method, params, success, failure) 53 | if !s:registered 54 | call lspoints#denops#register() 55 | endif 56 | call denops#plugin#wait_async('lspoints', {->denops#request_async('lspoints', a:method, a:params, a:success, a:failure)}) 57 | endfunction 58 | -------------------------------------------------------------------------------- /autoload/lspoints/internal.vim: -------------------------------------------------------------------------------- 1 | function lspoints#internal#notify_change_params(bufnr) abort 2 | let changedtick = getbufvar(a:bufnr, 'changedtick') 3 | " Note: can't get changedtick from wiped buffer 4 | if empty(changedtick) 5 | return 6 | endif 7 | let uri = lspoints#util#bufnr_to_uri(a:bufnr) 8 | let text = lspoints#util#get_text(a:bufnr) 9 | return [a:bufnr, uri, text, changedtick] 10 | endfunction 11 | 12 | function lspoints#internal#notify_change(bufnr) abort 13 | let params = lspoints#internal#notify_change_params(a:bufnr) 14 | if empty(params) 15 | return 16 | endif 17 | call denops#notify('lspoints', 'notifyChange', params) 18 | endfunction 19 | -------------------------------------------------------------------------------- /autoload/lspoints/settings.vim: -------------------------------------------------------------------------------- 1 | function! lspoints#settings#get() abort 2 | return lspoints#denops#request('getSettings', []) 3 | endfunction 4 | 5 | function! lspoints#settings#set(settings) abort 6 | call lspoints#denops#request('setSettings', [a:settings], v:true) 7 | endfunction 8 | 9 | function! lspoints#settings#patch(settings) abort 10 | call lspoints#denops#request('patchSettings', [a:settings], v:true) 11 | endfunction 12 | -------------------------------------------------------------------------------- /autoload/lspoints/util.vim: -------------------------------------------------------------------------------- 1 | function lspoints#util#bufnr_to_uri(bufnr) abort 2 | let bufname = bufname(a:bufnr)->fnamemodify(':p') 3 | if bufname =~# ':/' 4 | return bufname 5 | else 6 | return 'file://' .. bufname 7 | endif 8 | endfunction 9 | 10 | " Vim script側でjoin等やるのが速いので問題出るまでこうする 11 | function lspoints#util#get_text(bufnr) abort 12 | let buf = getbufline(a:bufnr, 1, '$') 13 | " textDocumentの末尾には改行入ってるっぽいので 14 | eval buf->add('') 15 | return buf->join("\n") 16 | endfunction 17 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "exports": "./denops/lspoints/interface.ts", 3 | "lock": false, 4 | "name": "@kuuote/lspoints", 5 | "tasks": { 6 | "check": "deno fmt --check denops && deno lint && deno test -A", 7 | "fmt": "deno fmt denops" 8 | }, 9 | "version": "0.1.1" 10 | } 11 | -------------------------------------------------------------------------------- /denops/@ddc-sources/lspoints.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseSource, 3 | type GatherArguments, 4 | } from "https://deno.land/x/ddc_vim@v4.3.1/base/source.ts"; 5 | import type { Item } from "https://deno.land/x/ddc_vim@v4.3.1/types.ts"; 6 | import * as u from "https://deno.land/x/unknownutil@v3.16.3/mod.ts"; 7 | import type * as LSPTypes from "npm:vscode-languageserver-types@3.17.5"; 8 | 9 | type Never = Record; 10 | 11 | const isClients = u.isArrayOf( 12 | u.isObjectOf({ 13 | name: u.isString, 14 | serverCapabilities: u.isRecord, 15 | }), 16 | ); 17 | 18 | export class Source extends BaseSource { 19 | override async gather({ 20 | denops, 21 | }: GatherArguments): Promise { 22 | const bufNr = Number(await denops.call("bufnr")); 23 | const _clients = await denops.dispatch("lspoints", "getClients", bufNr); 24 | u.assert(_clients, isClients); 25 | const clients = _clients.filter((client) => 26 | client.serverCapabilities.completionProvider != null 27 | ); 28 | if (clients.length === 0) { 29 | return []; 30 | } 31 | const uri = String( 32 | await denops.call("lspoints#util#bufnr_to_uri", bufNr), 33 | ); 34 | const line = Number(await denops.call("line", ".")); 35 | const col = Number(await denops.call("col", ".")); 36 | const params = { 37 | textDocument: { 38 | uri, 39 | }, 40 | // TODO: マルチバイト文字を考慮していないのでちゃんと計算すること 41 | position: { 42 | line: line - 1, 43 | character: col - 1, 44 | }, 45 | }; 46 | const items = await Promise.all(clients.map(async (client) => { 47 | const result = await denops.dispatch( 48 | "lspoints", 49 | "request", 50 | client.name, 51 | "textDocument/completion", 52 | params, 53 | ) as { 54 | items: LSPTypes.CompletionItem[]; 55 | } | null; 56 | if (result == null) { 57 | return []; 58 | } 59 | return Promise.resolve(result.items.map((i) => ({ 60 | abbr: i.label, 61 | word: i.insertText ?? i.label, 62 | }))); 63 | })).then((items) => items.flat()); 64 | return items; 65 | } 66 | 67 | override params(): Never { 68 | return {}; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /denops/@lspoints/format.ts: -------------------------------------------------------------------------------- 1 | import type { Denops } from "../lspoints/deps/denops.ts"; 2 | import type { LSP } from "../lspoints/deps/lsp.ts"; 3 | import { deadline } from "../lspoints/deps/std/async.ts"; 4 | import { assert, is } from "../lspoints/deps/unknownutil.ts"; 5 | import { BaseExtension, type Lspoints } from "../lspoints/interface.ts"; 6 | import { 7 | applyTextEdits, 8 | uriFromBufnr, 9 | } from "jsr:@uga-rosa/denops-lsputil@^0.10.1"; 10 | 11 | export class Extension extends BaseExtension { 12 | initialize(denops: Denops, lspoints: Lspoints) { 13 | lspoints.defineCommands("format", { 14 | execute: async (bufnr: unknown, timeout = 5000, selector?: unknown) => { 15 | assert(timeout, is.Number); 16 | assert(bufnr, is.Number); 17 | let clients = lspoints.getClients(bufnr).filter((c) => 18 | c.serverCapabilities.documentFormattingProvider != null 19 | ); 20 | if (is.String(selector)) { 21 | clients = clients.filter((c) => c.name === selector); 22 | } 23 | 24 | if (clients.length == 0) { 25 | throw Error("何のクライアントも選ばれてないわよ"); 26 | } 27 | 28 | const resultPromise = lspoints.request( 29 | clients[0].name, 30 | "textDocument/formatting", 31 | { 32 | textDocument: { 33 | uri: await uriFromBufnr(denops, bufnr), 34 | }, 35 | options: { 36 | tabSize: Number(await denops.call("shiftwidth")), 37 | insertSpaces: Boolean(await denops.eval("&l:expandtab")), 38 | }, 39 | }, 40 | ) as Promise; 41 | const result = await deadline(resultPromise, timeout) 42 | .catch(async () => { 43 | await denops.cmd(` 44 | echohl Error 45 | echomsg "Timeout!" 46 | echohl None 47 | `); 48 | }); 49 | if (result == null) { 50 | return; 51 | } 52 | await applyTextEdits(denops, bufnr, result); 53 | }, 54 | }); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /denops/@lspoints/nvim_diagnostics.ts: -------------------------------------------------------------------------------- 1 | import type { Denops } from "../lspoints/deps/denops.ts"; 2 | import type { LSP } from "../lspoints/deps/lsp.ts"; 3 | import { BaseExtension, type Lspoints } from "../lspoints/interface.ts"; 4 | 5 | export class Extension extends BaseExtension { 6 | initialize(denops: Denops, lspoints: Lspoints) { 7 | if (denops.meta.host !== "nvim") { 8 | return; 9 | } 10 | lspoints.subscribeNotify( 11 | "textDocument/publishDiagnostics", 12 | async (client, _params) => { 13 | const params = _params as LSP.PublishDiagnosticsParams; 14 | const path = params.uri.replace(/^file:\/\//, ""); 15 | const bufnr = Number(await denops.call("bufnr", path)); 16 | if (bufnr == -1) { 17 | return; 18 | } 19 | await denops.call("luaeval", "require('lspoints').notify(_A)", { 20 | client, 21 | bufnr, 22 | diagnostics: params.diagnostics.map((d) => ({ 23 | lnum: d.range.start.line, 24 | end_lnum: d.range["end"].line, 25 | col: d.range.start.character, 26 | end_col: d.range["end"].character, 27 | severity: d.severity, 28 | message: d.message, 29 | source: d.source, 30 | code: d.code, 31 | })), 32 | }); 33 | }, 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /denops/lspoints/app.ts: -------------------------------------------------------------------------------- 1 | import { loadBuiltins } from "./builtins.ts"; 2 | import { autocmd, type Denops, type Entrypoint } from "./deps/denops.ts"; 3 | import { as, assert, is } from "./deps/unknownutil.ts"; 4 | import { isStartOptions, type Settings } from "./interface.ts"; 5 | import { isArrayOrObject } from "./jsonrpc/message.ts"; 6 | import { Lspoints } from "./lspoints.ts"; 7 | 8 | const isNumberOrString = is.UnionOf([ 9 | is.Number, 10 | is.String, 11 | ]); 12 | 13 | export const main: Entrypoint = async (denops: Denops) => { 14 | const lspoints = new Lspoints(); 15 | denops.dispatcher = { 16 | getSettings() { 17 | return lspoints.settings.get(); 18 | }, 19 | setSettings(settings: unknown) { 20 | lspoints.settings.set(settings as Settings); 21 | }, 22 | patchSettings(settings: unknown) { 23 | lspoints.settings.patch(settings as Settings); 24 | }, 25 | async start( 26 | name: unknown, 27 | startOptions: unknown = {}, 28 | ) { 29 | assert(name, is.String); 30 | assert(startOptions, isStartOptions); 31 | await lspoints.start(denops, name, startOptions); 32 | }, 33 | async attach(id: unknown, bufNr: unknown) { 34 | assert(id, isNumberOrString); 35 | assert(bufNr, is.Number); 36 | await lspoints.attach(denops, id, bufNr); 37 | }, 38 | async notifyChange( 39 | bufNr: unknown, 40 | uri: unknown, 41 | text: unknown, 42 | changedtick: unknown, 43 | ) { 44 | assert(bufNr, is.Number); 45 | assert(uri, is.String); 46 | assert(text, is.String); 47 | assert(changedtick, is.Number); 48 | await lspoints.notifyChange(bufNr, uri, text, changedtick); 49 | }, 50 | getClients(bufNr: unknown) { 51 | assert(bufNr, is.Number); 52 | return lspoints.getClients(bufNr); 53 | }, 54 | async notify( 55 | id: unknown, 56 | method: unknown, 57 | params: unknown = {}, 58 | ): Promise { 59 | assert(id, isNumberOrString); 60 | assert(method, is.String); 61 | assert(params, as.Optional(isArrayOrObject)); 62 | await lspoints.notify(id, method, params); 63 | }, 64 | async request( 65 | id: unknown, 66 | method: unknown, 67 | params: unknown = {}, 68 | ): Promise { 69 | assert(id, isNumberOrString); 70 | assert(method, is.String); 71 | assert(params, as.Optional(isArrayOrObject)); 72 | return await lspoints.request(id, method, params); 73 | }, 74 | async loadExtensions(ext: unknown) { 75 | assert(ext, is.ArrayOf(is.String)); 76 | // from global, must be de-duplicate 77 | await lspoints.loadExtensions(denops, [...new Set(ext)]); 78 | }, 79 | async executeCommand( 80 | extensionName: unknown, 81 | command: unknown, 82 | ...args: unknown[] 83 | ) { 84 | assert(extensionName, is.String); 85 | assert(command, is.String); 86 | return await lspoints.executeCommand(extensionName, command, ...args); 87 | }, 88 | }; 89 | await autocmd.group(denops, "lspoints.internal", (helper) => { 90 | helper.remove("*"); 91 | helper.define("User", "LspointsAttach:*", ":"); 92 | }); 93 | await loadBuiltins(denops, lspoints); 94 | await denops.dispatcher.loadExtensions( 95 | await denops.eval("g:lspoints#extensions"), 96 | ); 97 | return { 98 | [Symbol.asyncDispose]: async () => { 99 | lspoints.killall(); 100 | await autocmd.group(denops, "lspoints.internal", (helper) => { 101 | helper.remove("*"); 102 | }); 103 | }, 104 | }; 105 | }; 106 | -------------------------------------------------------------------------------- /denops/lspoints/box.ts: -------------------------------------------------------------------------------- 1 | import { deepMerge, type DeepMergeOptions } from "./deps/std/deep_merge.ts"; 2 | 3 | export class PatchableObjectBox> { 4 | #store: T; 5 | constructor(defaultValue: T) { 6 | this.#store = defaultValue; 7 | } 8 | 9 | get(): T { 10 | return this.#store; 11 | } 12 | 13 | set(value: T) { 14 | this.#store = value; 15 | } 16 | 17 | patch(value: Partial, options: DeepMergeOptions = { 18 | arrays: "replace", 19 | }) { 20 | this.#store = deepMerge(this.#store, value, options); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /denops/lspoints/builtin/configuration.ts: -------------------------------------------------------------------------------- 1 | import type { Denops } from "../deps/denops.ts"; 2 | import { as, assert, is, maybe } from "../deps/unknownutil.ts"; 3 | import { BaseExtension, type Lspoints } from "../interface.ts"; 4 | 5 | function getSettings( 6 | lspoints: Lspoints, 7 | clientName: string, 8 | ): Record | undefined { 9 | const client = lspoints.getClient(clientName); 10 | if (client == null) { 11 | return; 12 | } 13 | const settings = client.options.settings ?? client.options.params?.settings; 14 | return maybe(settings, is.Record); 15 | } 16 | 17 | export class Extension extends BaseExtension { 18 | initialize(_denops: Denops, lspoints: Lspoints) { 19 | lspoints.subscribeAttach(async (clientName, _bufnr) => { 20 | const settings = getSettings(lspoints, clientName); 21 | if (settings == null) { 22 | return; 23 | } 24 | await lspoints.notify(clientName, "workspace/didChangeConfiguration", { 25 | settings, 26 | }); 27 | }); 28 | lspoints.subscribeRequest( 29 | "workspace/configuration", 30 | (clientName, params) => { 31 | const settings = getSettings(lspoints, clientName); 32 | assert( 33 | params, 34 | is.ObjectOf({ 35 | items: is.ArrayOf(is.ObjectOf({ 36 | section: as.Optional(is.String), 37 | })), 38 | }), 39 | ); 40 | return params.items.map((item) => settings?.[item.section!] ?? null); 41 | }, 42 | ); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /denops/lspoints/builtin/diagnostics.ts: -------------------------------------------------------------------------------- 1 | import type { Denops } from "../deps/denops.ts"; 2 | import type { LSP } from "../deps/lsp.ts"; 3 | import { BaseExtension, type Lspoints } from "../interface.ts"; 4 | 5 | type FlatDiagnostic = { 6 | client: string; 7 | bufnr: number; 8 | diagnostic: LSP.Diagnostic; 9 | }; 10 | 11 | function ensure(map: Map, key: T, def: () => U): U { 12 | const value = map.get(key); 13 | if (value != null) { 14 | return value; 15 | } 16 | const defValue = def(); 17 | map.set(key, defValue); 18 | return defValue; 19 | } 20 | 21 | export class Extension extends BaseExtension { 22 | diagnostics: Map>> = new Map(); 23 | 24 | initialize(denops: Denops, lspoints: Lspoints) { 25 | lspoints.subscribeNotify( 26 | "textDocument/publishDiagnostics", 27 | async (clientName, _params) => { 28 | const params = _params as LSP.PublishDiagnosticsParams; 29 | const path = params.uri.replace(/^file:\/\//, ""); 30 | const bufNr = Number(await denops.call("bufnr", path)); 31 | if (bufNr == -1) { 32 | return; 33 | } 34 | const diagnosticsMap = ensure( 35 | this.diagnostics, 36 | clientName, 37 | () => new Map(), 38 | ); 39 | if (params.diagnostics.length == 0) { 40 | diagnosticsMap.delete(bufNr); 41 | } else { 42 | diagnosticsMap.set(bufNr, params.diagnostics); 43 | } 44 | }, 45 | ); 46 | lspoints.defineCommands("lspoints.diagnostics", { 47 | get: () => this.diagnostics, 48 | getFlat: () => { 49 | const result: FlatDiagnostic[][] = []; 50 | for (const [client, nd] of this.diagnostics.entries()) { 51 | for (const [bufnr, diagnostics] of nd.entries()) { 52 | result.push(diagnostics.map((diagnostic) => ({ 53 | client, 54 | bufnr, 55 | diagnostic, 56 | }))); 57 | } 58 | } 59 | return result.flat(); 60 | }, 61 | }); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /denops/lspoints/builtin/did_save.ts: -------------------------------------------------------------------------------- 1 | import { autocmd, type Denops } from "../deps/denops.ts"; 2 | import { BaseExtension, type Lspoints } from "../interface.ts"; 3 | 4 | export class Extension extends BaseExtension { 5 | async initialize(denops: Denops, lspoints: Lspoints) { 6 | await autocmd.group(denops, "lspoints.internal", (helper) => { 7 | helper.define( 8 | "BufWritePost", 9 | "*", 10 | "call denops#notify('lspoints', 'executeCommand', ['lspoints.did_save', 'handle', bufnr()])", 11 | ); 12 | }); 13 | lspoints.defineCommands("lspoints.did_save", { 14 | handle: (_bufnr: unknown) => { 15 | const bufnr = Number(_bufnr); 16 | for (const client of lspoints.getClients(bufnr)) { 17 | client.notify("textDocument/didSave", { 18 | textDocument: { 19 | uri: client.getUriFromBufNr(bufnr), 20 | }, 21 | }); 22 | } 23 | }, 24 | }); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /denops/lspoints/builtins.ts: -------------------------------------------------------------------------------- 1 | import { Extension as ConfigurationExtension } from "./builtin/configuration.ts"; 2 | import { Extension as DiagnosticsExtension } from "./builtin/diagnostics.ts"; 3 | import { Extension as DidSaveExtension } from "./builtin/did_save.ts"; 4 | import type { Denops } from "./deps/denops.ts"; 5 | import type { Lspoints } from "./interface.ts"; 6 | 7 | export async function loadBuiltins(denops: Denops, lspoints: Lspoints) { 8 | new DiagnosticsExtension().initialize(denops, lspoints); 9 | new ConfigurationExtension().initialize(denops, lspoints); 10 | await new DidSaveExtension().initialize(denops, lspoints); 11 | } 12 | -------------------------------------------------------------------------------- /denops/lspoints/client.ts: -------------------------------------------------------------------------------- 1 | import type { Denops } from "./deps/denops.ts"; 2 | import type { LSP } from "./deps/lsp.ts"; 3 | import type { Settings, StartOptions } from "./interface.ts"; 4 | import { JsonRpcClient, type LogHandler } from "./jsonrpc/jsonrpc_client.ts"; 5 | import { version } from "./version.ts"; 6 | 7 | export type ClientOptions = { 8 | rootPath?: string; 9 | rootUri?: string; 10 | initializationOptions?: Record; 11 | }; 12 | 13 | async function createPrettyTracer( 14 | clientName: string, 15 | dir: string, 16 | ): Promise { 17 | await Deno.mkdir(dir).catch(() => {}); 18 | const path = dir.replace(/\/?$/, "/") + `${clientName}_${Date.now()}.log`; 19 | async function write(type: string, msg: unknown) { 20 | const text = [ 21 | `☆ ${type}`, 22 | JSON.stringify(msg, null, "\t"), 23 | "", // last newline 24 | ].join("\n"); 25 | await Deno.writeTextFile(path, text, { 26 | append: true, 27 | }); 28 | } 29 | return { 30 | onRead: async (msg) => { 31 | await write("r", msg); 32 | }, 33 | onWrite: async (msg) => { 34 | await write("w", msg); 35 | }, 36 | onStderr: async (msg) => { 37 | await write("e", msg); 38 | }, 39 | }; 40 | } 41 | 42 | let clientID = 0; 43 | 44 | export class LanguageClient { 45 | denops: Denops; 46 | name: string; 47 | options: StartOptions; 48 | 49 | id = clientID++; 50 | rpcClient: JsonRpcClient; 51 | #attachedBuffers: Record = {}; 52 | #documentVersions: Record = {}; 53 | 54 | serverCapabilities: LSP.ServerCapabilities = {}; 55 | 56 | constructor(denops: Denops, name: string, startOptions: StartOptions) { 57 | this.denops = denops; 58 | this.name = name; 59 | this.options = startOptions; 60 | 61 | if (startOptions.cmd == null) { 62 | throw "cmd not specify"; 63 | } 64 | this.rpcClient = new JsonRpcClient( 65 | startOptions.cmd, 66 | startOptions.cmdOptions, 67 | ); 68 | } 69 | 70 | kill() { 71 | this.rpcClient.kill(); 72 | } 73 | 74 | async initialize(settings: Settings) { 75 | if (settings.tracePath != null) { 76 | this.rpcClient.logger.subscribe( 77 | await createPrettyTracer(this.name, settings.tracePath), 78 | ); 79 | } 80 | let rootUri: string | null = null; 81 | if (this.options.rootUri != null) { 82 | rootUri = String(this.options.rootUri); 83 | } else if (this.options.rootPath != null) { 84 | rootUri = "file://" + String(this.options.rootPath); 85 | } 86 | const response = await this.rpcClient.request( 87 | "initialize", 88 | { 89 | clientInfo: { 90 | name: "lspoints", 91 | version, 92 | }, 93 | processId: Deno.pid, 94 | capabilities: settings.clientCapabilities, 95 | initializationOptions: this.options.initializationOptions ?? {}, 96 | rootUri, 97 | } satisfies LSP.InitializeParams, 98 | ) as LSP.InitializeResult; 99 | await this.rpcClient.notify("initialized", {}); 100 | this.serverCapabilities = response.capabilities; 101 | return this; 102 | } 103 | 104 | async attach(bufNr: number) { 105 | const params = await this.denops.call( 106 | "lspoints#internal#notify_change_params", 107 | bufNr, 108 | ) as [number, string, string, number] | 0; 109 | if (params !== 0) { 110 | await this.notifyChange(...params); 111 | } 112 | } 113 | 114 | getUriFromBufNr(bufNr: number) { 115 | return this.#attachedBuffers[bufNr] ?? ""; 116 | } 117 | 118 | getDocumentVersion(bufNr: number) { 119 | return this.#documentVersions[this.#attachedBuffers[bufNr] ?? ""] ?? -1; 120 | } 121 | 122 | isAttached(bufNr: number): boolean { 123 | return this.#attachedBuffers[bufNr] != null; 124 | } 125 | 126 | async notifyChange( 127 | bufNr: number, 128 | uri: string, 129 | text: string, 130 | version: number, 131 | ) { 132 | const storedUri = this.#attachedBuffers[bufNr]; 133 | // :saveasしたとかattachしてないとかでuri違ったらLS側に開き直すようにお願いする 134 | if (uri !== storedUri) { 135 | if (storedUri != null) { 136 | await this.rpcClient.notify("textDocument/didClose", { 137 | textDocument: { 138 | uri, 139 | }, 140 | }); 141 | } 142 | const filetype = String( 143 | await this.denops.call("getbufvar", bufNr, "&filetype"), 144 | ); 145 | await this.rpcClient.notify("textDocument/didOpen", { 146 | textDocument: { 147 | uri, 148 | languageId: filetype, 149 | version, 150 | text, 151 | }, 152 | }); 153 | this.#attachedBuffers[bufNr] = uri; 154 | return; 155 | } 156 | this.#documentVersions[uri] = version; 157 | await this.rpcClient.notify("textDocument/didChange", { 158 | textDocument: { 159 | uri, 160 | version, 161 | }, 162 | contentChanges: [{ 163 | text, 164 | }], 165 | }); 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /denops/lspoints/deps/async.ts: -------------------------------------------------------------------------------- 1 | export { Lock } from "jsr:@lambdalisue/async@^2.0.0"; 2 | -------------------------------------------------------------------------------- /denops/lspoints/deps/denops.ts: -------------------------------------------------------------------------------- 1 | export * as autocmd from "jsr:@denops/std@^7.0.0/autocmd"; 2 | export type { Denops, Entrypoint } from "jsr:@denops/std@^7.0.0"; 3 | -------------------------------------------------------------------------------- /denops/lspoints/deps/lsp.ts: -------------------------------------------------------------------------------- 1 | export * as LSP from "npm:vscode-languageserver-protocol@3.17.5"; 2 | export * as LSPTypes from "npm:vscode-languageserver-types@3.17.5"; 3 | -------------------------------------------------------------------------------- /denops/lspoints/deps/std/async.ts: -------------------------------------------------------------------------------- 1 | export { deadline } from "jsr:@std/async@^1.0.0"; 2 | -------------------------------------------------------------------------------- /denops/lspoints/deps/std/bytes.ts: -------------------------------------------------------------------------------- 1 | export { concat, indexOfNeedle } from "jsr:@std/bytes@^1.0.0"; 2 | -------------------------------------------------------------------------------- /denops/lspoints/deps/std/deep_merge.ts: -------------------------------------------------------------------------------- 1 | export { deepMerge, type DeepMergeOptions } from "jsr:@std/collections@^1.0.0"; 2 | -------------------------------------------------------------------------------- /denops/lspoints/deps/std/path.ts: -------------------------------------------------------------------------------- 1 | export * as stdpath from "jsr:@std/path@^1.0.0"; 2 | -------------------------------------------------------------------------------- /denops/lspoints/deps/std/stream.ts: -------------------------------------------------------------------------------- 1 | export { TextLineStream } from "jsr:@std/streams@^1.0.0"; 2 | -------------------------------------------------------------------------------- /denops/lspoints/deps/unknownutil.ts: -------------------------------------------------------------------------------- 1 | export * from "jsr:@core/unknownutil@^4.0.0"; 2 | -------------------------------------------------------------------------------- /denops/lspoints/interface.ts: -------------------------------------------------------------------------------- 1 | import type { PatchableObjectBox } from "./box.ts"; 2 | import type { Denops } from "./deps/denops.ts"; 3 | import type { LSP } from "./deps/lsp.ts"; 4 | import { as, is, type Predicate } from "./deps/unknownutil.ts"; 5 | 6 | type Promisify = T | Promise; 7 | 8 | // deno-lint-ignore no-explicit-any 9 | type ArrayOrObject = Array | Record; 10 | 11 | export type StartOptions = { 12 | cmd?: string[]; 13 | cmdOptions?: Record; 14 | initializationOptions?: Record; 15 | settings?: Record; 16 | params?: Record; 17 | rootPath?: string; 18 | rootUri?: string; 19 | }; 20 | 21 | export const isStartOptions: Predicate = is.ObjectOf({ 22 | cmd: as.Optional(is.ArrayOf(is.String)), 23 | cmdOptions: as.Optional(is.RecordOf(is.Unknown)), 24 | initializationOptions: as.Optional(is.Record), 25 | settings: as.Optional(is.Record), 26 | params: as.Optional(is.Record), 27 | rootPath: as.Optional(is.String), 28 | rootUri: as.Optional(is.String), 29 | }); 30 | 31 | export type Client = { 32 | name: string; 33 | id: number; 34 | notify: (method: string, params?: ArrayOrObject) => Promise; 35 | request: ( 36 | method: string, 37 | params?: ArrayOrObject, 38 | options?: { signal: AbortSignal }, 39 | ) => Promise; 40 | serverCapabilities: LSP.ServerCapabilities; 41 | getUriFromBufNr(bufnr: number): string; 42 | getDocumentVersion(bufnr: number): number; 43 | isAttached(bufnr: number): boolean; 44 | options: StartOptions; 45 | }; 46 | 47 | export type Settings = { 48 | clientCapabilities: LSP.ClientCapabilities; 49 | startOptions: Record; 50 | requestTimeout: number; 51 | tracePath?: string; 52 | }; 53 | 54 | export type AttachCallback = ( 55 | clientName: string, 56 | bufnr: number, 57 | ) => Promisify; 58 | 59 | export type NotifyCallback = ( 60 | clientName: string, 61 | params: unknown, 62 | ) => Promisify; 63 | 64 | export type RequestCallback = ( 65 | clientName: string, 66 | params: unknown, 67 | ) => Promisify; 68 | 69 | export type CommandResult = unknown | Promise; 70 | export type Command = (...args: unknown[]) => CommandResult; 71 | 72 | export interface Lspoints { 73 | readonly settings: PatchableObjectBox; 74 | 75 | getClient(name: string): Client | undefined; 76 | 77 | getClients(bufNr: number): Client[]; 78 | 79 | notify( 80 | name: string, 81 | method: string, 82 | params: ArrayOrObject, 83 | ): Promise; 84 | 85 | request( 86 | name: string, 87 | method: string, 88 | params: ArrayOrObject, 89 | ): Promise; 90 | 91 | subscribeAttach(callback: AttachCallback): void; 92 | 93 | subscribeNotify( 94 | method: string, 95 | callback: NotifyCallback, 96 | ): void; 97 | 98 | subscribeRequest( 99 | method: string, 100 | callback: RequestCallback, 101 | ): void; 102 | 103 | defineCommands( 104 | extensionName: string, 105 | commands: Record, 106 | ): void; 107 | 108 | executeCommand( 109 | extensionName: string, 110 | command: string, 111 | ...args: unknown[] 112 | ): CommandResult; 113 | } 114 | 115 | export abstract class BaseExtension { 116 | abstract initialize(denops: Denops, lspoints: Lspoints): void | Promise; 117 | } 118 | -------------------------------------------------------------------------------- /denops/lspoints/jsonrpc/jsonrpc_client.ts: -------------------------------------------------------------------------------- 1 | import { concat } from "../deps/std/bytes.ts"; 2 | import { TextLineStream } from "../deps/std/stream.ts"; 3 | import { JsonRpcStream } from "./jsonrpc_stream.ts"; 4 | import { 5 | isNotifyMessage, 6 | isRequestMessage, 7 | isResponseMessage, 8 | type NotifyMessage, 9 | type RequestMessage, 10 | } from "./message.ts"; 11 | 12 | type Promisify = T | Promise; 13 | 14 | const encoder = new TextEncoder(); 15 | 16 | function jsonRpcEncode(data: unknown): Uint8Array { 17 | const buf = encoder.encode(JSON.stringify(data)); 18 | const header = `Content-Length: ${buf.byteLength}\r\n\r\n`; 19 | const headerRaw = encoder.encode(header); 20 | return concat([headerRaw, buf]); 21 | } 22 | 23 | class Logger { 24 | handlers = new Array(); 25 | 26 | onRead(msg: unknown) { 27 | for (const handler of this.handlers) { 28 | handler.onRead?.(msg); 29 | } 30 | } 31 | onWrite(msg: unknown) { 32 | for (const handler of this.handlers) { 33 | handler.onWrite?.(msg); 34 | } 35 | } 36 | onStderr(msg: string) { 37 | for (const handler of this.handlers) { 38 | handler.onStderr?.(msg); 39 | } 40 | } 41 | subscribe(handler: LogHandler) { 42 | this.handlers.push(handler); 43 | } 44 | } 45 | 46 | export interface LogHandler { 47 | onRead?: (msg: unknown) => Promisify; 48 | onWrite?: (msg: unknown) => Promisify; 49 | onStderr?: (msg: string) => Promisify; 50 | } 51 | 52 | export class JsonRpcClient { 53 | #process: Deno.ChildProcess; 54 | #w: WritableStreamDefaultWriter; 55 | 56 | #requestId = 0; 57 | #requestPool: Record void, (e: unknown) => void]> = 58 | {}; 59 | 60 | notifyHandlers: Array<(msg: NotifyMessage) => Promisify> = []; 61 | requestHandlers: Array< 62 | (msg: RequestMessage) => Promisify 63 | > = []; 64 | logger = new Logger(); 65 | 66 | constructor( 67 | command: string[], 68 | options?: Record, 69 | ) { 70 | this.#process = new Deno.Command(command[0], { 71 | ...options, 72 | args: command.slice(1), 73 | stdin: "piped", 74 | stdout: "piped", 75 | stderr: "piped", 76 | }).spawn(); 77 | this.#process.stdout 78 | .pipeThrough(new JsonRpcStream()) 79 | .pipeTo( 80 | new WritableStream({ 81 | write: (chunk: unknown) => { 82 | this.logger.onRead(chunk); 83 | if (isRequestMessage(chunk)) { 84 | (async () => { 85 | for (const handler of this.requestHandlers) { 86 | const result = await handler(chunk); 87 | if (result !== undefined) { 88 | this.#sendMessage({ 89 | jsonrpc: "2.0", 90 | id: chunk.id, 91 | result, 92 | }); 93 | return; 94 | } 95 | } 96 | })().catch(console.trace); 97 | } else if (isResponseMessage(chunk)) { 98 | const id = Number(chunk.id); 99 | const cb = this.#requestPool[id]; 100 | if (cb == null) { 101 | // console.log("unresolved response: " + id); 102 | // console.log(chunk); 103 | } else { 104 | if (chunk.result !== undefined) { // contains null 105 | cb[0](chunk.result); 106 | } else if (chunk.error != null) { 107 | cb[1](JSON.stringify(chunk.error)); 108 | } 109 | delete this.#requestPool[id]; 110 | } 111 | } else if (isNotifyMessage(chunk)) { 112 | for (const notifier of this.notifyHandlers) { 113 | notifier(chunk)?.catch(console.log); 114 | } 115 | } else { 116 | console.log("unresolved chunk"); 117 | console.log(chunk); 118 | } 119 | }, 120 | }), 121 | ); 122 | this.#process.stderr 123 | .pipeThrough(new TextDecoderStream()) 124 | .pipeThrough(new TextLineStream()) 125 | .pipeTo( 126 | new WritableStream({ 127 | write: (line: string) => { 128 | this.logger.onStderr(line); 129 | }, 130 | }), 131 | ); 132 | this.#w = this.#process.stdin.getWriter(); 133 | } 134 | 135 | kill() { 136 | this.#process.kill(); 137 | } 138 | 139 | async #sendMessage(msg: unknown) { 140 | this.logger.onWrite(msg); 141 | await this.#w.write(jsonRpcEncode(msg)); 142 | } 143 | 144 | async notify( 145 | method: string, 146 | params?: unknown[] | Record, 147 | ): Promise { 148 | const msg: NotifyMessage = { 149 | jsonrpc: "2.0", 150 | method, 151 | }; 152 | if (params != null) { 153 | msg.params = params; 154 | } 155 | await this.#sendMessage(msg); 156 | } 157 | 158 | async request( 159 | method: string, 160 | params?: unknown[] | Record, 161 | options?: { signal?: AbortSignal }, 162 | ): Promise { 163 | const id = this.#requestId++; 164 | const msg: RequestMessage = { 165 | jsonrpc: "2.0", 166 | id, 167 | method, 168 | }; 169 | if (params != null) { 170 | msg.params = params; 171 | } 172 | await this.#sendMessage(msg); 173 | options?.signal?.addEventListener("abort", () => { 174 | this.notify("$/cancelRequest", { id }); 175 | }); 176 | return new Promise((resolve, reject) => { 177 | this.#requestPool[id] = [resolve, reject]; 178 | }); 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /denops/lspoints/jsonrpc/jsonrpc_stream.ts: -------------------------------------------------------------------------------- 1 | import { concat, indexOfNeedle } from "../deps/std/bytes.ts"; 2 | 3 | const rn = new Uint8Array([0xd, 0xa]); 4 | const decoder = new TextDecoder(); 5 | 6 | enum Mode { 7 | Header = 0, 8 | Content = 1, 9 | } 10 | 11 | export class JsonRpcStream extends TransformStream { 12 | #mode = Mode.Header; 13 | #contentLength = -1; 14 | #buf = new Uint8Array(); 15 | 16 | constructor() { 17 | super({ 18 | transform: (chunk, controller) => { 19 | this.#handle(chunk, controller); 20 | }, 21 | }); 22 | } 23 | 24 | #handle( 25 | chunk: Uint8Array, 26 | controller: TransformStreamDefaultController, 27 | ) { 28 | this.#buf = concat([this.#buf, chunk]); 29 | while (true) { 30 | if (this.#mode === Mode.Header) { 31 | while (true) { 32 | const found = indexOfNeedle(this.#buf, rn); 33 | if (found === -1) { 34 | return; 35 | } 36 | const headerRaw = this.#buf.subarray(0, found); 37 | this.#buf = this.#buf.subarray(found + 2); 38 | if (headerRaw.length === 0) { 39 | // terminal 40 | if (this.#contentLength === -1) { 41 | throw new Error("Content-Lengthが指定されてねーぞゴルァ"); 42 | } 43 | this.#mode = Mode.Content; 44 | break; 45 | } else { 46 | const header = decoder.decode(headerRaw); 47 | const match = header.match(/Content-Length: (\d+)/); 48 | const length = Number(match?.[1]); 49 | if (!isNaN(length)) { 50 | this.#contentLength = length; 51 | } 52 | } 53 | } 54 | } 55 | if (this.#mode === Mode.Content) { 56 | if (this.#contentLength <= this.#buf.length) { 57 | const content = this.#buf.subarray(0, this.#contentLength); 58 | this.#buf = this.#buf.subarray(this.#contentLength); 59 | controller.enqueue(JSON.parse(decoder.decode(content))); 60 | this.#mode = Mode.Header; 61 | } else { 62 | return; 63 | } 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /denops/lspoints/jsonrpc/message.ts: -------------------------------------------------------------------------------- 1 | import { as, is, type PredicateType } from "../deps/unknownutil.ts"; 2 | 3 | export const isArrayOrObject = is.UnionOf([ 4 | is.Array, 5 | is.RecordOf(is.Any), 6 | ]); 7 | 8 | export type ArrayOrObject = PredicateType; 9 | 10 | const isMessage = is.ObjectOf({ 11 | jsonrpc: is.LiteralOf("2.0"), 12 | }); 13 | 14 | export const isNotifyMessage = is.IntersectionOf([ 15 | isMessage, 16 | is.ObjectOf({ 17 | method: is.String, 18 | params: as.Optional(isArrayOrObject), 19 | }), 20 | ]); 21 | 22 | export type NotifyMessage = PredicateType; 23 | 24 | const isID = is.UnionOf([ 25 | is.Number, 26 | is.String, 27 | ]); 28 | 29 | export const isRequestMessage = is.IntersectionOf([ 30 | isNotifyMessage, 31 | is.ObjectOf({ 32 | id: isID, 33 | }), 34 | ]); 35 | 36 | export type RequestMessage = PredicateType; 37 | 38 | const isResponseError = is.ObjectOf({ 39 | code: is.Number, 40 | message: is.String, 41 | data: as.Optional(is.UnionOf([ 42 | is.String, 43 | is.Number, 44 | is.Boolean, 45 | isArrayOrObject, 46 | is.Null, 47 | ])), 48 | }); 49 | 50 | export const isResponseMessage = is.IntersectionOf([ 51 | isMessage, 52 | is.ObjectOf({ 53 | id: isID, 54 | result: as.Optional(is.UnionOf([ 55 | is.String, 56 | is.Number, 57 | is.Boolean, 58 | isArrayOrObject, 59 | is.Null, 60 | ])), 61 | error: as.Optional(isResponseError), 62 | }), 63 | ]); 64 | 65 | export type ResponseMessage = PredicateType; 66 | -------------------------------------------------------------------------------- /denops/lspoints/lspoints.ts: -------------------------------------------------------------------------------- 1 | import { PatchableObjectBox } from "./box.ts"; 2 | import { LanguageClient } from "./client.ts"; 3 | import { Lock } from "./deps/async.ts"; 4 | import { autocmd, type Denops } from "./deps/denops.ts"; 5 | import { deadline } from "./deps/std/async.ts"; 6 | import { deepMerge } from "./deps/std/deep_merge.ts"; 7 | import { stdpath } from "./deps/std/path.ts"; 8 | import type { 9 | AttachCallback, 10 | BaseExtension, 11 | Client, 12 | Command, 13 | NotifyCallback, 14 | RequestCallback, 15 | Settings, 16 | StartOptions, 17 | } from "./interface.ts"; 18 | import type { ArrayOrObject } from "./jsonrpc/message.ts"; 19 | 20 | const lock = new Lock(null); 21 | 22 | const clientCache = new WeakMap(); 23 | // transform client implementation to description object 24 | function transformClient(client: LanguageClient): Client { 25 | const cached = clientCache.get(client); 26 | if (cached != null) { 27 | return cached; 28 | } 29 | const transformed: Client = { 30 | name: client.name, 31 | id: client.id, 32 | notify: client.rpcClient.notify.bind(client.rpcClient), 33 | request: client.rpcClient.request.bind(client.rpcClient), 34 | serverCapabilities: client.serverCapabilities, 35 | getUriFromBufNr: client.getUriFromBufNr.bind(client), 36 | getDocumentVersion: client.getDocumentVersion.bind(client), 37 | isAttached: client.isAttached.bind(client), 38 | options: client.options, 39 | }; 40 | clientCache.set(client, transformed); 41 | return transformed; 42 | } 43 | 44 | export class Lspoints { 45 | commands: Record> = {}; 46 | attachHandlers: Array = []; 47 | notifyHandlers: Record> = {}; 48 | requestHandlers: Record> = {}; 49 | clients: Record = {}; 50 | clientIDs: Record = {}; 51 | settings: PatchableObjectBox = new PatchableObjectBox({ 52 | clientCapabilities: { 53 | general: { 54 | positionEncodings: [ 55 | "utf-16", 56 | ], 57 | }, 58 | textDocument: { 59 | // https://github.com/hrsh7th/cmp-nvim-lsp/blob/44b16d11215dce86f253ce0c30949813c0a90765/lua/cmp_nvim_lsp/init.lua#L37 60 | completion: { 61 | dynamicRegistration: false, 62 | completionItem: { 63 | snippetSupport: true, 64 | commitCharactersSupport: true, 65 | deprecatedSupport: true, 66 | preselectSupport: true, 67 | tagSupport: { 68 | valueSet: [ 69 | 1, // Deprecated 70 | ], 71 | }, 72 | insertReplaceSupport: true, 73 | resolveSupport: { 74 | properties: [ 75 | "documentation", 76 | "detail", 77 | "additionalTextEdits", 78 | "sortText", 79 | "filterText", 80 | "insertText", 81 | "textEdit", 82 | "insertTextFormat", 83 | "insertTextMode", 84 | ], 85 | }, 86 | insertTextModeSupport: { 87 | valueSet: [ 88 | 1, // asIs 89 | 2, // adjustIndentation 90 | ], 91 | }, 92 | labelDetailsSupport: true, 93 | }, 94 | contextSupport: true, 95 | insertTextMode: 1, 96 | completionList: { 97 | itemDefaults: [ 98 | "commitCharacters", 99 | "editRange", 100 | "insertTextFormat", 101 | "insertTextMode", 102 | "data", 103 | ], 104 | }, 105 | }, 106 | }, 107 | }, 108 | requestTimeout: 5000, 109 | startOptions: {}, 110 | }); 111 | 112 | #getClient(id: number | string): LanguageClient | undefined { 113 | if (typeof id === "number") { 114 | return this.clientIDs[id]; 115 | } else { 116 | return this.clients[id]; 117 | } 118 | } 119 | 120 | async start( 121 | denops: Denops, 122 | name: string, 123 | options: StartOptions = {}, 124 | ) { 125 | if (this.clients[name] == null) { 126 | await lock.lock(async () => { 127 | const defaultOptions = this.settings.get().startOptions[name]; 128 | if (defaultOptions != null) { 129 | options = deepMerge(defaultOptions, options, { 130 | arrays: "replace", 131 | }); 132 | } 133 | // TODO: TCP接続とか対応する 134 | const client = await new LanguageClient( 135 | denops, 136 | name, 137 | options, 138 | ) 139 | .initialize(this.settings.get()); 140 | this.clients[name] = client; 141 | this.clientIDs[client.id] = client; 142 | client.rpcClient.notifyHandlers.push(async (msg) => { 143 | for (const notifier of this.notifyHandlers[msg.method] ?? []) { 144 | await notifier(name, msg.params); 145 | } 146 | }); 147 | client.rpcClient.requestHandlers.push(async (msg) => { 148 | for (const handler of this.requestHandlers[msg.method] ?? []) { 149 | const result = await handler(name, msg.params); 150 | if (result !== undefined) { 151 | return result; 152 | } 153 | } 154 | }); 155 | }); 156 | } 157 | } 158 | 159 | // kill all clients to avoids dangling process 160 | killall() { 161 | for (const client of Object.values(this.clients)) { 162 | client.kill(); 163 | } 164 | } 165 | 166 | async attach(denops: Denops, id: string | number, bufNr: number) { 167 | let name = ""; 168 | await lock.lock(async () => { 169 | const client = this.#getClient(id); 170 | if (client == null) { 171 | throw Error(`client "${id}" is not started`); 172 | } 173 | name = client.name; 174 | await client.attach(bufNr); 175 | }); 176 | for (const handler of this.attachHandlers) { 177 | await handler(name, bufNr); 178 | } 179 | await autocmd.group( 180 | denops, 181 | "lspoints.internal", 182 | (helper) => { 183 | helper.remove("*", ""); 184 | helper.define( 185 | ["TextChanged", "TextChangedI"], 186 | "", 187 | `call lspoints#internal#notify_change(${bufNr})`, 188 | ); 189 | }, 190 | ); 191 | await autocmd.emit(denops, "User", `LspointsAttach:${name}:${bufNr}`); 192 | } 193 | 194 | async notifyChange( 195 | bufNr: number, 196 | uri: string, 197 | text: string, 198 | changedtick: number, 199 | ) { 200 | const clients = Object.values(this.clients) 201 | .filter((client) => client.isAttached(bufNr)); 202 | for (const client of clients) { 203 | await client.notifyChange(bufNr, uri, text, changedtick); 204 | } 205 | } 206 | 207 | getClient(name: number | string): Client | undefined { 208 | const client = this.#getClient(name); 209 | if (client == null) { 210 | return; 211 | } 212 | return transformClient(client); 213 | } 214 | 215 | getClients(bufNr: number): Client[] { 216 | return Object.entries(this.clients) 217 | .sort((a, b) => a[0].localeCompare(b[0])) 218 | .filter((entry) => entry[1].isAttached(bufNr)) 219 | .map((entry) => transformClient(entry[1])); 220 | } 221 | 222 | async notify( 223 | id: string | number, 224 | method: string, 225 | params: ArrayOrObject = {}, 226 | ): Promise { 227 | if (lock.locked) { 228 | await lock.lock(() => {}); 229 | } 230 | const client = this.#getClient(id); 231 | if (client == null) { 232 | throw Error(`client "${id}" is not started`); 233 | } 234 | await client.rpcClient.notify(method, params); 235 | } 236 | 237 | async request( 238 | id: string | number, 239 | method: string, 240 | params: ArrayOrObject = {}, 241 | ): Promise { 242 | if (lock.locked) { 243 | await lock.lock(() => {}); 244 | } 245 | const client = this.#getClient(id); 246 | if (client == null) { 247 | throw Error(`client "${id}" is not started`); 248 | } 249 | const timeout = this.settings.get().requestTimeout; 250 | return await deadline(client.rpcClient.request(method, params), timeout) 251 | .catch((e) => { 252 | if (!(e instanceof DOMException)) { 253 | return Promise.reject(e); 254 | } 255 | return client.denops.cmd( 256 | [ 257 | "echohl Error", 258 | 'echomsg "lspoints: request timeout"', 259 | "echomsg client", 260 | "echomsg method", 261 | "for p in params | echomsg p | endfor", 262 | "echohl None", 263 | ].join("\n"), 264 | { 265 | client: "client: " + client.name, 266 | method: "method: " + method, 267 | params: ("params: " + JSON.stringify(params, undefined, " ")) 268 | .split("\n"), 269 | }, 270 | ); 271 | }); 272 | } 273 | 274 | async loadExtensions(denops: Denops, path: string[]) { 275 | for (let p of path) { 276 | if (p.indexOf("/") == -1) { 277 | p = String( 278 | await denops.eval( 279 | `globpath(&runtimepath, 'denops/@lspoints/${p}.ts')`, 280 | ), 281 | ).replace(/\n.*/, ""); 282 | } 283 | // NOTE: Import module with fragment so that reload works properly. 284 | // https://github.com/vim-denops/denops.vim/issues/227 285 | const mod = await import( 286 | `${stdpath.toFileUrl(p).href}#${performance.now()}` 287 | ); 288 | await (new mod.Extension() as BaseExtension).initialize(denops, this); 289 | } 290 | } 291 | 292 | subscribeAttach(callback: AttachCallback) { 293 | (this.attachHandlers = this.attachHandlers ?? []).push(callback); 294 | } 295 | 296 | subscribeNotify( 297 | method: string, 298 | callback: NotifyCallback, 299 | ) { 300 | (this.notifyHandlers[method] = this.notifyHandlers[method] ?? []).push( 301 | callback, 302 | ); 303 | } 304 | 305 | subscribeRequest( 306 | method: string, 307 | callback: RequestCallback, 308 | ) { 309 | (this.requestHandlers[method] = this.requestHandlers[method] ?? []).push( 310 | callback, 311 | ); 312 | } 313 | 314 | defineCommands(extensionName: string, commands: Record) { 315 | this.commands[extensionName] = commands; 316 | } 317 | 318 | async executeCommand( 319 | extensionName: string, 320 | command: string, 321 | ...args: unknown[] 322 | ): Promise { 323 | return await this.commands[extensionName][command](...args); 324 | } 325 | } 326 | -------------------------------------------------------------------------------- /denops/lspoints/version.ts: -------------------------------------------------------------------------------- 1 | export const version = "v0.1.1"; 2 | -------------------------------------------------------------------------------- /doc/config.ts: -------------------------------------------------------------------------------- 1 | import type { Denops } from "jsr:@denops/std@7.0.0"; 2 | import { 3 | BaseExtension, 4 | type Lspoints, 5 | } from "https://deno.land/x/lspoints@v0.0.7/interface.ts"; 6 | 7 | // place to {runtimepath}/denops/@lspoints/config.ts 8 | 9 | export class Extension extends BaseExtension { 10 | override initialize(_denops: Denops, lspoints: Lspoints) { 11 | lspoints.settings.patch({ 12 | startOptions: { 13 | // TypeScript way to given options 14 | denols: { 15 | cmd: ["deno", "lsp"], 16 | settings: { 17 | deno: { 18 | enable: true, 19 | unstable: true, 20 | }, 21 | }, 22 | }, 23 | luals: { 24 | cmd: ["lua-language-server"], 25 | settings: { 26 | Lua: { 27 | diagnostics: { 28 | globals: ["vim"], 29 | }, 30 | }, 31 | }, 32 | }, 33 | }, 34 | }); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /doc/lspoints.jax: -------------------------------------------------------------------------------- 1 | *lspoints.txt* denops.vimを使ってLSPをお喋りします。 2 | 3 | help書けてないのでぼちぼち書きます 4 | 暫定的に使ってる設定の写しが `./lspoints_example.vim` にあります(|gf|で見れます) 5 | 6 | ============================================================================== 7 | ☆概要 *lspoints-introduction* 8 | 9 | *lspoints* は Language Server と Vim のバッファを接続し、リクエストを送信した 10 | り、診断などの通知を受信したりするための機能を提供します。 11 | 12 | *lspoints-synopsis* 13 | > 14 | " カレントバッファに `deno lsp` を attach する 15 | function s:attach_denols() abort 16 | call lspoints#attach('denols', #{ 17 | \ cmd: ['deno', 'lsp'], 18 | \ initializationOptions: #{ 19 | \ enable: v:true, 20 | \ unstable: v:true, 21 | \ suggest: #{ 22 | \ autoImports: v:false, 23 | \ }, 24 | \ }, 25 | \ }) 26 | endfunction 27 | call s:attach_denols() 28 | 29 | " ファイルを開いた時に attach する 30 | autocmd FileType typescript,typescriptreact call s:attach_denols() 31 | 32 | " 起動時に拡張を読み込む 33 | autocmd User DenopsPluginPost:lspoints 34 | \ call lspoints#load_extensions([ 35 | \ 'nvim_diagnostics', 36 | \ ]) 37 | 38 | < 39 | ============================================================================== 40 | API *lspoints-api* 41 | 42 | *lspoints#reload()* 43 | lspoints#reload() 44 | denops.vimに命令を送り、lspointsの再起動を行います。 45 | サーバーは全て終了し、アタッチも全て解除されます。 46 | 自動でサーバーが起動したりはしないため、何らかの方法で再起動する必要 47 | があります。 48 | 49 | *lspoints#start()* 50 | lspoints#start({name}, [{options}]) 51 | {name} で示される言語サーバーを起動します。 52 | は言語サーバーが立ち上がっていない時に起動するために使用されます。 53 | {options} は以下の要素から成ります: 54 | 55 | cmd: 56 | サーバーを起動するためのコマンドライン。文字列のリスト。 57 | cmdOptions: 58 | サーバーを起動する際に `Deno.Command()` の `options` 引数に渡 59 | す辞書です。ただし `args`, `stdin`, `stdout`, `sterr`の4つの 60 | フィールドの値は内部的に決定されます。 61 | initializationOptions: 62 | サーバーに渡される初期化オプション 63 | settings: 64 | ワークスペース設定の初期値。 65 | `workspace/didChangeConfiguration` 及び 66 | `workspace/configuration` によりサーバーに渡されます。 67 | 68 | *lspoints#attach()* 69 | lspoints#attach({name}, [{options}]) 70 | カレントバッファに {name} で示される言語サーバーをアタッチします。 71 | {options} は言語サーバーが立ち上がっていない時に起動するために 72 | 使用されます。立ち上がっている場合は無視されるため、省略も可能です。 73 | |lspoints#start()| に渡されます。 74 | 75 | *lspoints#load_extensions()* 76 | lspoints#load_extensions({pathes}) 77 | {pathes} に指定されたリストを|g:lspoints#extensions|に追加し、 78 | 指定された拡張を読み込みます。 79 | vimrc内でも実行可能で、その場合は追加だけが行われます。 80 | 各要素は文字列で、名前が指定された場合は |'runtimepath'| より 81 | `denops/@lspoints/{path}.ts` にマッチする物が読み込まれます。 82 | フルパスが指定されている場合はそのパスから読み込まれます。 83 | 84 | *lspoints#get_clients()* 85 | lspoints#get_clients([{bufnr}]) 86 | {bufnr} で示されるバッファにアタッチされているクライアントのリストを 87 | 取得します。 88 | {bufnr} が省略された場合はカレントバッファが使用されます。 89 | 90 | *lspoints#notify()* 91 | lspoints#notify({name}, {method}, [{params}]) 92 | {name} で示される言語サーバーに {method} の通知を、省略可能な 93 | {params} を引数にして送信します。 94 | 95 | *lspoints#request()* 96 | lspoints#request({name}, {method}, [{params}]) 97 | {name} で示される言語サーバーに {method} のリクエストを、省略可能な 98 | {params} を引数にして送信し、結果を返します。 99 | 100 | *lspoints#request_async()* 101 | lspoints#request_async({name}, {method}, [{params}]) 102 | {name} で示される言語サーバーに {method} のリクエストを、{params} を 103 | 引数にして送信し、結果を引数に {success} か {failure} に渡した 104 | コールバックを呼び出します。 105 | 106 | ============================================================================== 107 | VARIABLE *lspoints-variable* 108 | 109 | *g:lspoints#extensions* 110 | 111 | |lspoints#load_extensions()|に渡す物と同じ要素からなるリストです。 112 | ここに指定した拡張はlspointsが起動する際に読み込まれます。 113 | 114 | vim:tw=78:ts=8:noet:ft=help:norl: 115 | -------------------------------------------------------------------------------- /doc/lspoints_example.vim: -------------------------------------------------------------------------------- 1 | " see also ./config.ts 2 | 3 | " function style 4 | call lspoints#load_extensions([ 5 | \ 'config', 6 | \ 'format', 7 | \ 'nvim_diagnostics', 8 | \ ]) 9 | " or variable style 10 | let g:lspoints#extensions = [ 11 | \ 'config', 12 | \ 'format', 13 | \ 'nvim_diagnostics', 14 | \ ] 15 | function s:post() abort 16 | call lspoints#settings#patch(#{ 17 | \ tracePath: '/tmp/lspoints', 18 | \ }) 19 | endfunction 20 | 21 | autocmd User DenopsPluginPost:lspoints call s:post() 22 | 23 | function s:attach_denols() abort 24 | " Vim script way to given options 25 | call lspoints#attach('denols', #{ 26 | \ cmd: ['deno', 'lsp'], 27 | \ settings: #{ 28 | \ deno: #{ 29 | \ enable: v:true, 30 | \ unstable: v:true, 31 | \ suggest: #{ 32 | \ autoImports: v:false, 33 | \ }, 34 | \ }, 35 | \ }, 36 | \ }) 37 | endfunction 38 | 39 | autocmd FileType typescript,typescriptreact call s:attach_denols() 40 | 41 | function s:on_attach() abort 42 | nnoremap mf call denops#request('lspoints', 'executeCommand', ['format', 'execute', bufnr()]) 43 | endfunction 44 | 45 | autocmd User LspointsAttach:* call s:on_attach() 46 | -------------------------------------------------------------------------------- /lua/lspoints.lua: -------------------------------------------------------------------------------- 1 | local namespaces = {} 2 | 3 | local M = {} 4 | 5 | vim.api.nvim_create_autocmd('User', { 6 | pattern = 'DenopsPluginPre:lspoints', 7 | callback = function() 8 | for _, namespace in pairs(namespaces) do 9 | vim.diagnostic.reset(namespace) 10 | end 11 | end, 12 | }) 13 | 14 | function M.notify(msg) 15 | if namespaces[msg.client] == nil then 16 | namespaces[msg.client] = vim.api.nvim_create_namespace('lspoints.diagnostics.' .. msg.client) 17 | end 18 | local namespace = namespaces[msg.client] 19 | vim.diagnostic.reset(namespace, msg.bufnr) 20 | vim.diagnostic.set(namespace, msg.bufnr, msg.diagnostics) 21 | end 22 | 23 | return M 24 | --------------------------------------------------------------------------------