├── LICENSE ├── README.md ├── langserver.py ├── libkak.py ├── lspc.py ├── run_tests.sh ├── test └── mock_ls.py └── utils.py /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Dan Rosén 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Try out the Language Server Protocol for Kakoune! 2 | 3 | Clone this repo, decorate your kakrc like so: 4 | 5 | ```kak 6 | # Commands for language servers 7 | decl str lsp_servers %{ 8 | python:pyls 9 | typescript:node javascript-typescript-langserver/lib/language-server-stdio.js 10 | javascript:node javascript-typescript-langserver/lib/language-server-stdio.js 11 | go:go-langserver 12 | } 13 | 14 | # Ignore E501 for python (Line length > 80 chars) 15 | decl str lsp-python-disabled-diagnostics '^E501' 16 | 17 | # Example keybindings 18 | map -docstring %{Goto definition} global user . ':lsp-goto-definition' 19 | map -docstring %{Select references} global user r ':lsp-references' 20 | map -docstring %{Hover help} global user h ':lsp-hover docsclient' 21 | map -docstring %{Next diagnostic} global user j ':lsp-diagnostics next cursor' 22 | map -docstring %{Previous diagnostic} global user k ':lsp-diagnostics prev cursor' 23 | 24 | # Manual completion and signature help when needed 25 | map global insert ':eval -draft %(exec b; lsp-complete)' 26 | map global insert ':lsp-signature-help' 27 | 28 | # Hover and diagnostics on idle 29 | hook -group lsp global NormalIdle .* %{ 30 | lsp-diagnostics cursor 31 | lsp-hover cursor 32 | } 33 | 34 | # Aggressive diagnostics 35 | hook -group lsp global InsertEnd .* lsp-sync 36 | 37 | 38 | ``` 39 | 40 | Then attach `lspc.py` to a running kak process. Say it has PID 4032 (you see 41 | this in the lower right corner), then issue: 42 | 43 | python lspc.py 4032 44 | 45 | If you want to start this from Kakoune add something like this to your kakrc: 46 | 47 | ```kak 48 | def lsp-start %{ 49 | %sh{ 50 | ( python $HOME/code/libkak/lspc.py $kak_session 51 | ) > /dev/null 2>&1 < /dev/null & 52 | } 53 | } 54 | ``` 55 | 56 | Change the path accordingly. 57 | 58 | Happy hacking! 59 | 60 | ## License 61 | 62 | MIT 63 | -------------------------------------------------------------------------------- /langserver.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import print_function 4 | from collections import defaultdict, OrderedDict 5 | from six.moves.queue import Queue 6 | from subprocess import Popen, PIPE 7 | from threading import Thread 8 | import pprint 9 | import itertools as it 10 | import json 11 | import os 12 | import six 13 | import sys 14 | import tempfile 15 | import utils 16 | import functools 17 | import re 18 | 19 | 20 | class Langserver(object): 21 | 22 | def __init__(self, pwd, cmd, push=None, mock={}): 23 | self.cbs = {} 24 | self.diagnostics = defaultdict(dict) 25 | self.push = push or utils.noop 26 | self.pwd = pwd 27 | 28 | if cmd in mock: 29 | self.proc = mock[cmd] 30 | else: 31 | self.proc = Popen(cmd.split(), stdin=PIPE, 32 | stdout=PIPE, stderr=sys.stderr) 33 | 34 | t = Thread(target=Langserver.spawn, args=(self,)) 35 | t.start() 36 | print('thread', t, 'started for', self.proc) 37 | 38 | def craft(self, method, params, cb=None, _private={'n': 0}): 39 | """ 40 | Assigns to cbs 41 | """ 42 | obj = { 43 | 'method': method, 44 | 'params': params 45 | } 46 | if cb: 47 | n = '{}-{}'.format(method, _private['n']) 48 | obj['id'] = n 49 | self.cbs[n] = cb 50 | _private['n'] += 1 51 | return utils.jsonrpc(obj) 52 | 53 | def call(self, method, params): 54 | """ 55 | craft assigns to cbs 56 | """ 57 | 58 | def k(cb=None): 59 | msg = self.craft(method, params, cb) 60 | self.proc.stdin.write(msg) 61 | self.proc.stdin.flush() 62 | print('sent:', method) 63 | return k 64 | 65 | def spawn(self): 66 | 67 | rootUri = 'file://' + self.pwd 68 | 69 | self.call('initialize', { 70 | 'processId': os.getpid(), 71 | 'rootUri': rootUri, 72 | 'rootPath': self.pwd, 73 | 'capabilities': {} 74 | })(lambda msg: self.push('initialize', msg.get('result', {}))) 75 | 76 | contentLength = 0 77 | while not self.proc.stdout.closed: 78 | line = self.proc.stdout.readline().decode('utf-8').strip() 79 | # typescript-langserver has this extra Header: 80 | line = utils.drop_prefix(line, 'Header: ') 81 | if line: 82 | header, value = line.split(":") 83 | if header == "Content-Length": 84 | contentLength = int(value) 85 | else: 86 | content = self.proc.stdout.read(contentLength).decode('utf-8') 87 | if content == "": 88 | continue 89 | try: 90 | msg = json.loads(content) 91 | except Exception: 92 | if not self.proc.stdout.closed: 93 | msg = "Error deserializing server output: " + content 94 | print(msg, file=sys.stderr) 95 | continue 96 | print('Response from langserver:', '\n'.join( 97 | pprint.pformat(msg).split('\n')[:40])) 98 | if msg.get('id') in self.cbs: 99 | cb = self.cbs[msg['id']] 100 | del self.cbs[msg['id']] 101 | if 'error' in msg: 102 | print('error', pprint.pformat(msg), file=sys.stderr) 103 | cb(msg) 104 | if 'id' not in msg and 'method' in msg: 105 | self.push(msg['method'], msg.get('params')) 106 | 107 | -------------------------------------------------------------------------------- /libkak.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import print_function 4 | from six.moves.queue import Queue 5 | from subprocess import Popen, PIPE 6 | from threading import Thread 7 | import functools 8 | import itertools as it 9 | import os 10 | import re 11 | import six 12 | import sys 13 | import tempfile 14 | import time 15 | import utils 16 | 17 | 18 | class Remote(object): 19 | 20 | def __init__(self, session): 21 | self.session = session 22 | self.pre = lambda _: '%sh(' 23 | self.post = ')' 24 | self.arg_config = {} 25 | self.puns = True 26 | self.argnames = [] 27 | self.sync_setup = False 28 | self.required_names = {'client'} 29 | 30 | def ret(): 31 | x = self.listen() 32 | self.fifo_cleanup() 33 | return x 34 | self.ret = ret 35 | 36 | @staticmethod 37 | def _resolve(self_or_session): 38 | if isinstance(self_or_session, Remote): 39 | return self_or_session 40 | else: 41 | return Remote(self_or_session) 42 | 43 | @staticmethod 44 | def setup_reply_channel(self_or_session): 45 | r = Remote._resolve(self_or_session) 46 | r_pre = r.pre 47 | r.pre = lambda f: r_pre(f) + ''' 48 | __reply_fifo_dir=$(mktemp -d) 49 | __reply_fifo="${__reply_fifo_dir}/fifo" 50 | mkfifo ${__reply_fifo} 51 | ''' 52 | r.post = ''' 53 | \ncat ${__reply_fifo} 54 | rm ${__reply_fifo} 55 | rmdir ${__reply_fifo_dir} 56 | ''' + r.post 57 | r.arg_config['reply_fifo'] = ('__reply_fifo', Args.string) 58 | r.required_names.add('reply_fifo') 59 | return r 60 | 61 | @staticmethod 62 | def asynchronous(self_or_session): 63 | r = Remote._resolve(self_or_session) 64 | r_ret = r.ret 65 | r.ret = lambda: utils.fork()(r_ret) 66 | return r 67 | 68 | @staticmethod 69 | def onclient(self_or_session, client, sync=True): 70 | r = Remote._resolve(self_or_session) 71 | r_pre = r.pre 72 | r.pre = lambda f: 'eval -client ' + client + ' %(' + r_pre(f) 73 | r.post = ')' + r.post 74 | if not sync: 75 | r.asynchronous(r) 76 | return r 77 | 78 | @staticmethod 79 | def hook(self_or_session, scope, name, group=None, filter='.*', 80 | sync_setup=False, client=None): 81 | r = Remote._resolve(self_or_session) 82 | r.sync_setup = sync_setup 83 | group = ' -group ' + group if group else '' 84 | filter = utils.single_quoted(filter) 85 | cmd = 'hook' + group + ' ' + scope + ' ' + name + ' ' + filter + ' %(' 86 | r_pre = r.pre 87 | r.pre = lambda f: cmd + r_pre(f) 88 | r.post = ')' + r.post 89 | r.ret = lambda: utils.fork(loop=True)(r.listen) 90 | if client: 91 | r.onclient(r, client) 92 | return r 93 | 94 | def _f_name(self): 95 | return self.f.__name__.replace('_', '-') 96 | 97 | @staticmethod 98 | def command(self_or_session, params='0', enum=[], 99 | sync_setup=False, sync_python_calls=False, hidden=False): 100 | r = Remote._resolve(self_or_session) 101 | r.sync_setup = sync_setup 102 | 103 | def ret(): 104 | utils.fork(loop=True)(r.listen) 105 | 106 | @functools.wraps(r.f) 107 | def call_from_python(client, *args): 108 | escaped = [utils.single_quoted(arg) for arg in args] 109 | pipe(r.session, ' '.join([r._f_name()] + escaped), client, 110 | sync=sync_python_calls) 111 | return call_from_python 112 | r.ret = ret 113 | r_pre = r.pre 114 | 115 | def pre(f): 116 | s = 'def -allow-override -params {params} -docstring {docstring} {name} {hidden}' 117 | s = s.format(name=r._f_name(), 118 | params=params, 119 | hidden=(hidden and "-hidden") or '', 120 | docstring=utils.single_quoted(utils.deindent(f.__doc__ or ''))) 121 | if enum: 122 | sh = '\n'.join('[ $kak_token_to_complete -eq {} ] && printf "{}\n"'.format(i, '\\n'.join(es)) 123 | for i, es in enumerate(enum)) 124 | s += ' -shell-candidates %{' + sh + '} ' 125 | s += ' %(' 126 | s += r_pre(f) 127 | return s 128 | r.pre = pre 129 | r.post += ')' 130 | return r 131 | 132 | def _argnames(self): 133 | names = set(self.required_names) 134 | names.update(self.argnames) 135 | if self.puns: 136 | names.update(utils.argnames(self.f)) 137 | return list(names) 138 | 139 | @staticmethod 140 | def _msg(splices, fifo): 141 | underscores = [] 142 | argsplice = [] 143 | for s in splices: 144 | underscores.append('__' + s + '=${' + s + '//_/_u}') 145 | argsplice.append('${__' + s + '//$__newline/_n}') 146 | underscores = '\n'.join(underscores) 147 | argsplice = '_s'.join(argsplice) 148 | 149 | m = ["__newline='\n'"] 150 | if '__args' in splices: 151 | m.append('__args=""') 152 | m.append('for __arg; do __args="${__args}_S${__arg//_/_u}"; done') 153 | 154 | m.append(underscores) 155 | m.append('echo -n "' + argsplice + '" > ' + fifo) 156 | return '\n'.join(m) 157 | 158 | def __call__(self, f): 159 | self.f = f 160 | splices, self.parse = Args.argsetup(self._argnames(), self.arg_config) 161 | self.fifo, self.fifo_cleanup = _mkfifo() 162 | msg = self.pre(f) + self._msg(splices, self.fifo) + self.post 163 | pipe(self.session, msg, sync=self.sync_setup) 164 | return self.ret() 165 | 166 | def listen(self): 167 | _debug(self.f.__name__ + ' ' + self.fifo + ' waiting for call...') 168 | with open(self.fifo, 'r') as fp: 169 | line = utils.decode(fp.readline()).rstrip() 170 | if line == '_q': 171 | self.fifo_cleanup() 172 | _debug(self.fifo, 'demands quit') 173 | raise RuntimeError('fifo demands quit') 174 | _debug(self.f.__name__ + ' ' + self.fifo + ' replied:' + repr(line)) 175 | 176 | r = self.parse(line) 177 | 178 | try: 179 | def _pipe(msg, sync=False): 180 | return pipe(self.session, msg, r['client'], sync) 181 | r['pipe'] = _pipe 182 | d = {} 183 | if 'reply_fifo' in r: 184 | d['reply_calls'] = 0 185 | 186 | def reply(msg): 187 | d['reply_calls'] += 1 188 | with open(r['reply_fifo'], 'w') as fp: 189 | fp.write(msg) 190 | r['reply'] = reply 191 | result = utils.safe_kwcall(self.f, r) if self.puns else self.f(r) 192 | if 'reply_fifo' in r: 193 | if d['reply_calls'] != 1: 194 | print('!!! [ERROR] Must make exactly 1 call to reply, ' + 195 | self.f + ' ' + self.r + ' made ' + d['reply_calls'], 196 | file=sys.stderr) 197 | return result 198 | except TypeError as e: 199 | print(str(e), file=sys.stderr) 200 | 201 | 202 | def pipe(session, msg, client=None, sync=False): 203 | """ 204 | Send commands to a running Kakoune process. 205 | 206 | If sync is true, this function will return after 207 | the commands have been executed. 208 | 209 | >>> with tempfile.NamedTemporaryFile() as tmp: 210 | ... kak = headless() 211 | ... pipe(kak.pid, 'edit ' + tmp.name, 'unnamed0', sync=True) 212 | ... pipe(kak.pid, 'exec itest', 'unnamed0') 213 | ... pipe(kak.pid, 'write', 'unnamed0', sync=True) 214 | ... print(utils.decode(tmp.read()).rstrip()) 215 | ... pipe(kak.pid, 'quit', 'unnamed0', sync=True) 216 | ... kak.wait() 217 | test 218 | 0 219 | """ 220 | if client: 221 | import tempfile 222 | name = tempfile.mktemp() 223 | with open(name, 'wb') as tmp: 224 | tmp.write(utils.encode(msg)) 225 | msg = u'eval -client {} "%sh`cat {}; rm {}`"'.format(client, name, name) 226 | if sync: 227 | fifo, fifo_cleanup = _mkfifo() 228 | msg += u'\n%sh(echo done > {})'.format(fifo) 229 | # _debug('piping: ', msg.replace('\n', ' ')[:70]) 230 | _debug('piping: ', msg) 231 | if hasattr(session, '__call__'): 232 | session(msg) 233 | else: 234 | p = Popen(['kak', '-p', str(session).rstrip()], stdin=PIPE) 235 | p.stdin.write(utils.encode(msg)) 236 | p.stdin.flush() 237 | p.stdin.close() 238 | if sync: 239 | _debug(fifo + ' waiting for completion...', 240 | msg.replace('\n', ' ')[:60]) 241 | with open(fifo, 'r') as fifo_fp: 242 | fifo_fp.readline() 243 | _debug(fifo + ' going to clean up...') 244 | fifo_cleanup() 245 | _debug(fifo + ' done') 246 | 247 | 248 | ############################################################################# 249 | # Kakoune commands 250 | 251 | 252 | def select(cursors): 253 | """ 254 | A command to select some cursors. 255 | 256 | >>> print(select([((1,2),(1,4)), ((3,1),(5,72))])) 257 | select 1.2,1.4:3.1,5.72 258 | """ 259 | return 'select ' + ':'.join('%d.%d,%d.%d' % tuple(it.chain(*pos)) 260 | for pos in cursors) 261 | 262 | def change(range, new_text): 263 | """ 264 | A command to change some text 265 | 266 | >>> print(change(((1,2), (3,4)), 'test')) 267 | select 1.2,3.4; execute-keys -draft ctest 268 | """ 269 | return select([range]) + '; execute-keys -draft c' + new_text + '' 270 | 271 | def menu(options, auto_single=True): 272 | """ 273 | A command to make a menu. 274 | 275 | Takes a list of 2-tuples of an entry and the command it executes. 276 | 277 | >>> print(menu([('one', 'echo one'), ('two', 'echo two')])) 278 | menu 'one' 'echo one' 'two' 'echo two' 279 | >>> print(menu([('one', 'echo one')])) 280 | echo one 281 | >>> print(menu([('one', 'echo one')], auto_single=False)) 282 | menu 'one' 'echo one' 283 | """ 284 | options = list(options) 285 | if len(options) == 1 and auto_single: 286 | return options[0][1] 287 | opts = utils.join(map(utils.single_quoted, it.chain(*options))) 288 | return 'menu ' + opts 289 | 290 | 291 | def complete(line, column, timestamp, completions): 292 | u""" 293 | Format completion for a Kakoune option. 294 | 295 | >>> print(complete(5, 20, 1234, [ 296 | ... ('__doc__', 'object’s docstring', '__doc__ (method)'), 297 | ... ('||', 'logical or', '|| (func: infix)') 298 | ... ])) 299 | 5.20@1234:__doc__|object’s docstring|__doc__ (method):\|\||logical or|\|\| (func\: infix) 300 | """ 301 | rows = (utils.join((utils.backslash_escape('|:', x) for x in c), sep='|') 302 | for c in completions) 303 | return u'{}.{}@{}:{}'.format(line, column, timestamp, utils.join(rows, sep=':')) 304 | 305 | 306 | ############################################################################# 307 | # Arguments and argument parsers 308 | 309 | 310 | class Args(object): 311 | 312 | @staticmethod 313 | def coord(s): 314 | """ 315 | Parse a Kakoune coordinate. 316 | """ 317 | return tuple(map(int, s.split('.'))) 318 | 319 | @staticmethod 320 | def selection_desc(x): 321 | """ 322 | Parse a Kakoune selection description. 323 | """ 324 | return tuple(map(Args.coord, x.split(','))) 325 | 326 | @staticmethod 327 | def string(x): 328 | """ 329 | Parse a Kakoune string. 330 | """ 331 | return x 332 | 333 | @staticmethod 334 | def listof(p): 335 | r""" 336 | Parse a Kakoune list of p. 337 | 338 | >>> import random 339 | >>> def random_fragment(): 340 | ... return ''.join(random.sample(':\\abc', random.randrange(1, 5))) 341 | >>> def test(n): 342 | ... xs = [random_fragment() for _ in range(n)] 343 | ... if xs and xs[-1] == '': 344 | ... xs[-1] = 'c' 345 | ... exs = ':'.join(utils.backslash_escape('\\:', s) for s in xs) 346 | ... xs2 = Args.listof(Args.string)(exs) 347 | ... assert(xs == xs2) 348 | >>> for n in range(0, 10): 349 | ... test(n) 350 | 351 | """ 352 | def rmlastcolon(s): 353 | if s and s[-1] == ':': 354 | return s[:-1] 355 | else: 356 | return s 357 | 358 | def inner(s): 359 | ms = [m.group(0) 360 | for m in re.finditer(r'(.*?(?', x)) for x in ms] 364 | return inner 365 | 366 | @staticmethod 367 | def boolean(s): 368 | """ 369 | Parse a Kakoune boolean. 370 | """ 371 | return s == 'true' 372 | 373 | @staticmethod 374 | def args_parse(s): 375 | return tuple(x.replace('_u', '_') for x in s.split('_S')[1:]) 376 | 377 | @staticmethod 378 | def argsetup(argnames, config): 379 | """ 380 | >>> s, _ = Args.argsetup('client cmd reply'.split(), {'cmd': ('a', int)}) 381 | >>> print(s) 382 | ['kak_client', 'a'] 383 | """ 384 | args = [] 385 | splices = [] 386 | for name in argnames: 387 | try: 388 | if name in config: 389 | splice, parse = config[name] 390 | else: 391 | splice, parse = _arg_config[name] 392 | splices.append(splice) 393 | args.append((name, parse)) 394 | except KeyError: 395 | pass 396 | 397 | def parse(line): 398 | _debug(argnames, line) 399 | params = [v.replace('_n', '\n').replace('_u', '_') 400 | for v in line.split('_s')] 401 | return {name: parse(value) 402 | for (name, parse), value in zip(args, params)} 403 | return splices, parse 404 | 405 | 406 | _arg_config = { 407 | 'line': ('kak_cursor_line', int), 408 | 'column': ('kak_cursor_column', int), 409 | 410 | 'aligntab': ('kak_opt_aligntab', Args.boolean), 411 | 'filetype': ('kak_opt_filetype', Args.string), 412 | 'indentwidth': ('kak_opt_indentwidth', int), 413 | 'readonly': ('kak_opt_readonly', Args.boolean), 414 | 'readonly': ('kak_opt_readonly', Args.boolean), 415 | 'tabstop': ('kak_opt_tabstop', int), 416 | 'completers': ('kak_opt_completers', Args.listof(Args.string)), 417 | 418 | 'pwd': ('PWD', Args.string), 419 | 'PWD': ('PWD', Args.string), 420 | 'PATH': ('PATH', Args.string), 421 | 'HOME': ('HOME', Args.string), 422 | 423 | 'args': ('__args', Args.args_parse), 424 | 'arg1': ('1', Args.string), 425 | 'arg2': ('2', Args.string), 426 | 'arg3': ('3', Args.string), 427 | 'arg4': ('4', Args.string), 428 | 'arg5': ('5', Args.string), 429 | 'arg6': ('6', Args.string), 430 | 'arg7': ('7', Args.string), 431 | 'arg8': ('8', Args.string), 432 | 'arg9': ('9', Args.string), 433 | 434 | 'bufname': ('kak_bufname', Args.string), 435 | 'buffile': ('kak_buffile', Args.string), 436 | 'buflist': ('kak_buflist', Args.listof(Args.string)), 437 | 'timestamp': ('kak_timestamp', int), 438 | 'selection': ('kak_selection', Args.string), 439 | 'selections': ('kak_selections', Args.listof(Args.string)), 440 | 'runtime': ('kak_runtime', Args.string), 441 | 'session': ('kak_session', Args.string), 442 | 'client': ('kak_client', Args.string), 443 | 'cursor_line': ('kak_cursor_line', int), 444 | 'cursor_column': ('kak_cursor_column', int), 445 | 'cursor_char_column': ('kak_cursor_char_column', int), 446 | 'cursor_byte_offset': ('kak_cursor_byte_offset', int), 447 | 'selection_desc': ('kak_selection_desc', Args.selection_desc), 448 | 'selections_desc': ('kak_selections_desc', Args.listof(Args.selection_desc)), 449 | 'window_width': ('kak_window_width', int), 450 | 'window_height': ('kak_window_height', int), 451 | } 452 | 453 | 454 | ############################################################################# 455 | # Private utils 456 | 457 | 458 | def _mkfifo(active_fifos={}): 459 | """ 460 | Return a pair of a new fifo' filename and a cleanup function. 461 | """ 462 | fifo_dir = tempfile.mkdtemp() 463 | fifo = os.path.join(fifo_dir, 'fifo') 464 | os.mkfifo(fifo) 465 | 466 | def rm(): 467 | del active_fifos[fifo] 468 | os.remove(fifo) 469 | os.rmdir(fifo_dir) 470 | active_fifos[fifo] = rm 471 | return fifo, rm 472 | 473 | 474 | def _fifo_cleanup(): 475 | """ 476 | Writes _q to all open fifos created by _mkfifo. 477 | """ 478 | for x in list(six.iterkeys(_mkfifo.__defaults__[0])): 479 | with open(x, 'w') as fd: 480 | fd.write('_q\n') 481 | fd.flush() 482 | 483 | 484 | def _debug(*xs): 485 | if '-d' in sys.argv[1:]: 486 | print(*xs, file=sys.stderr) 487 | 488 | 489 | ############################################################################# 490 | # Tests 491 | 492 | 493 | def headless(ui='dummy', stdout=None): 494 | """ 495 | Start a headless Kakoune process. 496 | """ 497 | p = Popen(['kak', '-n', '-ui', ui], stdout=stdout) 498 | time.sleep(0.01) 499 | return p 500 | 501 | 502 | def _test_remote_commands_sync(): 503 | u""" 504 | >>> kak = headless() 505 | >>> @Remote.command(kak.pid, sync_setup=True) 506 | ... def write_position(line, column, pipe): 507 | ... pipe(utils.join(('exec ', 'a', str(line), ':', str(column), ''), sep=''), sync=True) 508 | >>> pipe(kak.pid, 'write-position', 'unnamed0', sync=True) 509 | >>> pipe(kak.pid, 'exec a,', 'unnamed0', sync=True) 510 | >>> write_position('unnamed0') 511 | >>> pipe(kak.pid, 'exec \%H', 'unnamed0', sync=True) 512 | >>> print(Remote.onclient(kak.pid, 'unnamed0')( 513 | ... lambda selection: selection)) 514 | 1:1, 1:5 515 | >>> r = Remote(kak.pid) 516 | >>> r.puns = False 517 | >>> r.required_names.add('selection') 518 | >>> print(r.onclient(r, 'unnamed0', sync=True)(lambda d: d['selection'])) 519 | 1:1, 1:5 520 | >>> q = Queue() 521 | >>> Remote.onclient(kak.pid, 'unnamed0', sync=False)( 522 | ... lambda selection: q.put(selection)) 523 | >>> print(q.get()) 524 | 1:1, 1:5 525 | >>> pipe(kak.pid, 'quit!', 'unnamed0') 526 | >>> kak.wait() 527 | 0 528 | >>> _fifo_cleanup() 529 | """ 530 | pass 531 | 532 | 533 | def _test_unicode_and_escaping(): 534 | u""" 535 | >>> kak = headless() 536 | >>> pipe(kak.pid, u'exec iapa_bepaåäö_s_u_n%H', 'unnamed0') 537 | >>> call = Remote.onclient(kak.pid, 'unnamed0') 538 | >>> print(call(lambda selection: selection)) 539 | apa_bepa 540 | åäö_s_u_n 541 | >>> print(call(lambda selection_desc: selection_desc)) 542 | ((1, 1), (2, 12)) 543 | >>> pipe(kak.pid, 'quit!', 'unnamed0') 544 | >>> kak.wait() 545 | 0 546 | >>> _fifo_cleanup() 547 | """ 548 | pass 549 | 550 | 551 | def _test_remote_commands_async(): 552 | u""" 553 | >>> kak = headless() 554 | >>> @Remote.command(kak.pid) 555 | ... def write_position(pipe, line, column): 556 | ... pipe(utils.join(('exec ', 'a', str(line), ':', str(column), ''), sep='')) 557 | >>> pipe(kak.pid, 'write-position', 'unnamed0') 558 | >>> time.sleep(0.05) 559 | >>> pipe(kak.pid, 'exec a,', 'unnamed0', sync=True) 560 | >>> time.sleep(0.02) 561 | >>> write_position('unnamed0') 562 | >>> pipe(kak.pid, 'exec \%H', 'unnamed0', sync=True) 563 | >>> Remote.onclient(kak.pid, 'unnamed0')(lambda selection: print(selection)) 564 | 1:1, 1:5 565 | >>> q = Queue() 566 | >>> Remote.onclient(kak.pid, 'unnamed0', sync=False)(lambda selection: q.put(selection)) 567 | >>> print(q.get()) 568 | 1:1, 1:5 569 | >>> pipe(kak.pid, 'quit!', 'unnamed0') 570 | >>> kak.wait() 571 | 0 572 | >>> _fifo_cleanup() 573 | """ 574 | pass 575 | 576 | 577 | def _test_commands_with_params(): 578 | u""" 579 | >>> kak = headless() 580 | >>> @Remote.command(kak.pid, params='2..', sync_python_calls=True) 581 | ... def test(arg1, arg2, args): 582 | ... print(', '.join((arg1, arg2) + args[2:])) 583 | >>> test(None, 'one', 'two', 'three', 'four') 584 | one, two, three, four 585 | >>> test(None, 'a\\nb', 'c_d', 'e_sf', 'g_u_n__ __n_S_s__Sh') 586 | a 587 | b, c_d, e_sf, g_u_n__ __n_S_s__Sh 588 | >>> pipe(kak.pid, "test 'a\\nb' c_d e_sf 'g_u_n__ __n_S_s__Sh'", sync=True) 589 | a 590 | b, c_d, e_sf, g_u_n__ __n_S_s__Sh 591 | >>> pipe(kak.pid, 'quit!', 'unnamed0') 592 | >>> kak.wait() 593 | 0 594 | >>> _fifo_cleanup() 595 | """ 596 | pass 597 | 598 | 599 | ############################################################################# 600 | # Main 601 | 602 | 603 | if __name__ == '__main__': 604 | import doctest 605 | doctest.testmod() 606 | -------------------------------------------------------------------------------- /lspc.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import print_function 4 | from collections import defaultdict, OrderedDict 5 | from six.moves.queue import Queue 6 | from subprocess import Popen, PIPE 7 | from threading import Thread 8 | import pprint 9 | import itertools as it 10 | import json 11 | import os 12 | import six 13 | import sys 14 | import tempfile 15 | import libkak 16 | import utils 17 | import functools 18 | import re 19 | from langserver import Langserver 20 | 21 | 22 | def edit_uri_select(uri, positions): 23 | filename = utils.uri_to_file(uri) 24 | if filename: 25 | return 'edit {}; {}'.format(filename, libkak.select(positions)) 26 | else: 27 | return 'echo -markup {red}Cannot open {}'.format(uri) 28 | 29 | 30 | def apply_textedit(textedit): 31 | return libkak.change(utils.range(textedit['range']), textedit['newText']) 32 | 33 | 34 | def apply_textdocumentedit(edit): 35 | filename = utils.uri_to_file(edit['textDocument']['uri']) 36 | if filename: 37 | return 'edit {}; {}; write'.format(filename, '; '.join(apply_textedit(textedit) 38 | for textedit in edit['edits'])) 39 | else: 40 | return 'echo -markup {red}Cannot open {}'.format(filename) 41 | 42 | 43 | def apply_workspaceedit(wsedit): 44 | if wsedit.get('documentChanges', None): 45 | return '; '.join(apply_textdocumentedit(edit) for edit in wsedit['documentChanges']) 46 | elif wsedit.get('changes', None): 47 | return 'echo -markup {red}Language server does not support documentChanges' 48 | else: 49 | return 'echo -markup {red}Invalid workspaceedit; echo -debug {}'.format(wsedit) 50 | 51 | def format_pos(pos): 52 | """ 53 | >>> print(format_pos({'line': 5, 'character': 0})) 54 | 6.1 55 | """ 56 | return '{}.{}'.format(pos['line'] + 1, pos['character'] + 1) 57 | 58 | 59 | somewhere = 'cursor info docsclient echo'.split() 60 | 61 | 62 | def info_somewhere(msg, pos, where): 63 | """ 64 | where = cursor | info | docsclient | echo 65 | """ 66 | if not msg: 67 | return 68 | msg = msg.rstrip() 69 | if where == 'cursor': 70 | return 'info -placement above -anchor {} {}'.format( 71 | format_pos(pos), utils.single_quoted(utils.join(msg.split('\n')[0:10], '\n'))) 72 | elif where == 'info': 73 | return 'info ' + utils.single_quoted(utils.join(msg.split('\n')[0:20], '\n')) 74 | elif where == 'docsclient': 75 | tmp = tempfile.mktemp() 76 | open(tmp, 'wb').write(utils.encode(msg)) 77 | return """ 78 | eval -no-hooks -try-client %opt[docsclient] %[ 79 | edit! -scratch '*doc*' 80 | exec \%d|cat {tmp} 81 | exec \%|fmt - %val[window_width] -s 82 | exec gg 83 | set buffer filetype rst 84 | try %[rmhl number_lines] 85 | %sh[rm {tmp}] 86 | ]""".format(tmp=tmp) 87 | elif where == 'echo': 88 | return 'echo ' + utils.single_quoted(msg.split('\n')[-1]) 89 | 90 | 91 | completionItemsKind = [ 92 | '', 93 | 'Text', 94 | 'Method', 95 | 'Function', 96 | 'Constructor', 97 | 'Field', 98 | 'Variable', 99 | 'Class', 100 | 'Interface', 101 | 'Module', 102 | 'Property', 103 | 'Unit', 104 | 'Value', 105 | 'Enum', 106 | 'Keyword', 107 | 'Snippet', 108 | 'Color', 109 | 'File', 110 | 'Reference', 111 | 'Folder', 112 | 'EnumMember', 113 | 'Constant', 114 | 'Struct', 115 | 'Event', 116 | 'Operator', 117 | 'TypeParameter', 118 | ] 119 | 120 | 121 | def complete_items(items): 122 | try: 123 | maxlen = max(len(item['label']) for item in items) 124 | except ValueError: 125 | maxlen = 0 126 | return (complete_item(item, maxlen) for item in items) 127 | 128 | 129 | def complete_item(item, maxlen): 130 | spaces = ' ' * (maxlen - len(item['label'])) 131 | kind_description = completionItemsKind[item.get('kind', 0)] 132 | if not kind_description: 133 | # match '(JSX Element)' and 'type' from typescript details 134 | derived = re.match('(\w+|\(.+?\))', item.get('detail', '')) 135 | if derived: 136 | kind_description = derived.group(1) 137 | menu_entry = item['label'] + spaces + ' {MenuInfo}' + kind_description 138 | return ( 139 | item['label'], 140 | '{}\n\n{}'.format(item.get('detail', ''), 141 | item.get('documentation', '')[:500]), 142 | menu_entry 143 | ) 144 | 145 | 146 | def pyls_signatureHelp(result, pos): 147 | sn = result['activeSignature'] 148 | pn = result['signatures'][sn].get('activeParameter', -1) 149 | func_label = result['signatures'][sn]['label'] 150 | params = result['signatures'][sn]['params'] 151 | return nice_sig(func_label, params, pn, pos) 152 | 153 | 154 | def nice_sig(func_label, params, pn, pos): 155 | try: 156 | func_name, _ = func_label.split('(', 1) 157 | except ValueError: 158 | func_name = func_label 159 | try: 160 | _, func_type = func_label.rsplit(')', 1) 161 | except ValueError: 162 | func_type = '' 163 | param_labels = [ 164 | ('*' if i == pn else '') + param['label'] + 165 | ('*' if i == pn else '') 166 | for i, param in enumerate(params) 167 | ] 168 | label = func_name + '(' + ', '.join(param_labels) + ')' + func_type 169 | pos['character'] -= len(func_name) + 1 170 | pos['character'] -= len(', '.join(param_labels[:pn])) 171 | if pn > 0: 172 | pos['character'] -= 1 173 | if pos['character'] < 0: 174 | pos['character'] = 0 175 | return label 176 | 177 | class Client: 178 | 179 | def __init__(self): 180 | self.langser = None 181 | 182 | self.langservers = {} 183 | self.timestamps = {} 184 | self.message_handlers = {} 185 | 186 | self.sig_help_chars = {} 187 | self.complete_chars = {} 188 | self.diagnostics = {} 189 | self.client_editing = {} 190 | self.original = {} 191 | self.chars_setup = set() 192 | self.session = None 193 | self.mock = None 194 | self.builders = {} 195 | 196 | def push_message(self, filetype): 197 | def k(method, params): 198 | self.message_handlers.get(method, utils.noop)(filetype, params) 199 | return k 200 | 201 | def make_sync(self, method, make_params): 202 | 203 | def sync(d, line, column, buffile, filetype, timestamp, pwd, cmd, client, reply): 204 | 205 | d['pos'] = {'line': line - 1, 'character': column - 1} 206 | d['uri'] = uri = 'file://' + six.moves.urllib.parse.quote(buffile) 207 | 208 | if cmd in self.langservers: 209 | print(filetype + ' already spawned') 210 | else: 211 | push = self.push_message(filetype) 212 | self.langservers[cmd] = Langserver(pwd, cmd, push, self.mock) 213 | 214 | if not client: 215 | print("Client was empty when syncing") 216 | 217 | d['langserver'] = langserver = self.langservers[cmd] 218 | 219 | old_timestamp = self.timestamps.get((filetype, buffile)) 220 | if old_timestamp == timestamp and not d['force']: 221 | print('no need to send update') 222 | reply('') 223 | else: 224 | self.timestamps[(filetype, buffile)] = timestamp 225 | with tempfile.NamedTemporaryFile() as tmp: 226 | write = "eval -no-hooks 'write {}'".format(tmp.name) 227 | libkak.pipe(reply, write, client=client, sync=True) 228 | print('finished writing to tempfile') 229 | contents = open(tmp.name, 'r').read() 230 | self.client_editing[filetype, buffile] = client 231 | if old_timestamp is None: 232 | langserver.call('textDocument/didOpen', { 233 | 'textDocument': { 234 | 'uri': uri, 235 | 'version': timestamp, 236 | 'languageId': filetype, 237 | 'text': contents 238 | } 239 | })() 240 | else: 241 | langserver.call('textDocument/didChange', { 242 | 'textDocument': { 243 | 'uri': uri, 244 | 'version': timestamp 245 | }, 246 | 'contentChanges': [{'text': contents}] 247 | })() 248 | print('sync: waiting for didChange reply...') 249 | print('sync: got didChange reply...') 250 | 251 | if method: 252 | print(method, 'calling langserver') 253 | q = Queue() 254 | langserver.call(method, utils.safe_kwcall( 255 | make_params, d))(q.put) 256 | return q.get() 257 | else: 258 | return {'result': None} 259 | 260 | return sync 261 | 262 | def message_handler(self, f): 263 | self.message_handlers[f.__name__.replace('_', '/')] = f 264 | return f 265 | 266 | def message_handler_named(self, name): 267 | def decorator(f): 268 | self.message_handlers[name] = f 269 | return f 270 | return decorator 271 | 272 | def handler(self, method=None, make_params=None, params='0', enum=None, force=False, hidden=False): 273 | def decorate(f): 274 | def builder(): 275 | self.original[f.__name__] = f 276 | 277 | r = libkak.Remote(self.session) 278 | r.command(r, params=params, enum=enum, sync_setup=True, hidden=hidden) 279 | r_pre = r.pre 280 | r.pre = lambda f: r_pre(f) + ''' 281 | [[ -z $kak_opt_filetype ]] && exit 282 | while read lsp_cmd; do 283 | IFS=':' read -ra x <<< "$lsp_cmd" 284 | if [[ $kak_opt_filetype == ${x[0]} ]]; then 285 | unset x[0] 286 | cmd="${x[@]}" 287 | ''' 288 | r.post = ''' 289 | break 290 | fi 291 | done <<< "$kak_opt_lsp_servers"''' + r.post 292 | r.setup_reply_channel(r) 293 | r.arg_config['cmd'] = ('cmd', libkak.Args.string) 294 | sync = self.make_sync(method, make_params) 295 | r.puns = False 296 | r.argnames = utils.argnames(sync) + utils.argnames(f) 297 | 298 | @functools.wraps(f) 299 | def k(d): 300 | try: 301 | d['d'] = d 302 | d['force'] = force 303 | # print('handler calls sync', pprint.pformat(d)) 304 | msg = utils.safe_kwcall(sync, d) 305 | # print('sync called', status, result, pprint.pformat(d)) 306 | if 'result' in msg: 307 | d['result'] = msg['result'] 308 | print('Calling', f.__name__, pprint.pformat(d)[:500]) 309 | msg = utils.safe_kwcall(f, d) 310 | if msg: 311 | print('Answer from', f.__name__, ':', msg) 312 | d['pipe'](msg) 313 | else: 314 | print('Error: ', msg) 315 | d['pipe'](''' 316 | echo -debug When handling {}: 317 | echo -debug {} 318 | echo -markup "{{red}}Error from language server (see *debug* buffer)" 319 | '''.format(utils.single_quoted(f.__name__), 320 | utils.single_quoted(pprint.pformat(msg)))) 321 | except: 322 | import traceback 323 | msg = f.__name__ + ' ' + traceback.format_exc() 324 | print(msg) 325 | d['pipe'](''' 326 | echo -debug When handling {}: 327 | echo -debug {} 328 | echo -markup "{{red}}Error from language client (see *debug* buffer)" 329 | '''.format(utils.single_quoted(f.__name__), utils.single_quoted(msg))) 330 | 331 | return r(k) 332 | self.builders[f.__name__] = builder 333 | return builder 334 | return decorate 335 | 336 | def pipe(self, msg, client=None, sync=False): 337 | libkak.pipe(self.session, msg, client, sync) 338 | 339 | def main(self, session, mock={}, messages=""): 340 | self.session = session 341 | self.mock = mock 342 | 343 | for k, builder in self.builders.items(): 344 | builder() 345 | 346 | libkak.pipe(session, """#kak 347 | remove-hooks global lsp 348 | try %{declare-option str lsp_servers} 349 | try %{declare-option str lsp_complete_chars} 350 | try %{declare-option str lsp_signature_help_chars} 351 | try %{declare-option completions lsp_completions} 352 | try %{declare-option line-specs lsp_flags} 353 | 354 | hook -group lsp global InsertChar .* %{ 355 | try %{ 356 | exec -no-hooks -draft h[ %opt{lsp_complete_chars} ] 357 | lsp-complete 358 | } 359 | try %{ 360 | exec -no-hooks -draft h[ %opt{lsp_signature_help_chars} ] 361 | lsp-signature-help 362 | } 363 | } 364 | 365 | # hook -group lsp global WinDisplay .* lsp-sync 366 | hook -group lsp global BufWritePost .* lsp-send-did-save 367 | hook -group lsp global BufClose .* lsp-buffer-deleted 368 | """ + messages) 369 | 370 | def makeClient(): 371 | client = Client() 372 | 373 | # Handlers 374 | 375 | @client.message_handler 376 | def initialize(filetype, result): 377 | capabilities = result.get('capabilities', {}) 378 | try: 379 | signatureHelp = capabilities['signatureHelpProvider'] 380 | client.sig_help_chars[filetype] = signatureHelp['triggerCharacters'] 381 | except KeyError: 382 | client.sig_help_chars[filetype] = [] 383 | except TypeError: 384 | sig_help_chars[filetype] = [] 385 | 386 | try: 387 | completionProvider = capabilities['completionProvider'] 388 | client.complete_chars[filetype] = completionProvider['triggerCharacters'] 389 | except KeyError: 390 | client.complete_chars[filetype] = [] 391 | except TypeError: 392 | sig_help_chars[filetype] = [] 393 | 394 | @client.message_handler 395 | def window_logMessage(filetype, params): 396 | 397 | libkak._debug('Log message', params) 398 | 399 | if 'message' in params: 400 | libkak._debug('Adding debug print', params) 401 | libkak.pipe(client.session, 'echo -debug ' + utils.single_quote_escape(params['message'])) 402 | 403 | if 'uri' not in params: 404 | return 405 | buffile = utils.uri_to_file(params['uri']) 406 | clientp = client.client_editing.get((filetype, buffile)) 407 | if not clientp: 408 | return 409 | 410 | libkak.pipe(client.session, 'echo ' + utils.single_quote_escape(params['message']), client=clientp) 411 | 412 | @client.message_handler 413 | def textDocument_publishDiagnostics(filetype, params): 414 | buffile = utils.uri_to_file(params['uri']) 415 | clientp = client.client_editing.get((filetype, buffile)) 416 | if not clientp: 417 | return 418 | r = libkak.Remote.onclient(client.session, clientp, sync=False) 419 | r.arg_config['disabled'] = ( 420 | 'kak_opt_lsp_' + filetype + '_disabled_diagnostics', 421 | libkak.Args.string) 422 | 423 | @r 424 | def _(timestamp, pipe, disabled): 425 | client.diagnostics[filetype, buffile] = defaultdict(list) 426 | client.diagnostics[filetype, buffile]['timestamp'] = timestamp 427 | flags = [str(timestamp), '1| '] 428 | from_severity = [ 429 | u'', 430 | u'{red}\u2022 ', 431 | u'{yellow}\u2022 ', 432 | u'{blue}\u2022 ', 433 | u'{green}\u2022 ' 434 | ] 435 | for diag in params['diagnostics']: 436 | if disabled and re.match(disabled, diag['message']): 437 | continue 438 | (line0, col0), end = utils.range(diag['range']) 439 | flags.append(str(line0) + '|' + 440 | from_severity[diag.get('severity', 1)]) 441 | client.diagnostics[filetype, buffile][line0].append({ 442 | 'col': col0, 443 | 'end': end, 444 | 'message': diag['message'] 445 | }) 446 | # todo: Set for the other buffers too (but they need to be opened) 447 | msg = 'try %{add-highlighter window/ flag_lines default lsp_flags}\n' 448 | msg += 'set buffer=' + buffile + ' lsp_flags ' 449 | msg += utils.single_quoted(':'.join(flags)) 450 | pipe(msg) 451 | 452 | @client.handler(force=True) 453 | def lsp_sync(buffile, filetype): 454 | """ 455 | Synchronize the current file. 456 | 457 | Makes sure that: 458 | * the language server is registered at the language server, 459 | * the language server has an up-to-date view on the buffer 460 | (even if it is not saved), 461 | * the options lsp_signature_help_chars and lsp_complete_chars 462 | are set for the buffer according to what the language server 463 | suggests. (These are examined at an InsertChar hook.) 464 | """ 465 | msg = '' 466 | if buffile not in client.chars_setup and ( 467 | client.sig_help_chars.get(filetype) or client.complete_chars.get(filetype)): 468 | client.chars_setup.add(buffile) 469 | 470 | def s(opt, chars): 471 | if chars: 472 | m = '\nset buffer=' 473 | m += buffile 474 | m += ' ' + opt 475 | m += ' ' + utils.single_quoted(''.join(chars)) 476 | return m 477 | else: 478 | return '' 479 | msg += s('lsp_signature_help_chars', client.sig_help_chars.get(filetype)) 480 | msg += s('lsp_complete_chars', client.complete_chars.get(filetype)) 481 | return msg 482 | 483 | @client.handler(hidden=True) 484 | def lsp_send_did_save(langserver, uri): 485 | """ 486 | Send textDocument/didSave to server 487 | """ 488 | langserver.call('textDocument/didSave', { 489 | 'textDocument': { 490 | 'uri': uri 491 | }, 492 | })() 493 | 494 | @client.handler(hidden=True) 495 | def lsp_buffer_deleted(filetype, buffile): 496 | """ 497 | Clear the data for a deleted buffer 498 | """ 499 | client.client_editing[filetype, buffile] = None 500 | client.timestamps[(filetype, buffile)] = None 501 | 502 | @client.handler('textDocument/signatureHelp', 503 | lambda pos, uri: { 504 | 'textDocument': {'uri': uri}, 505 | 'position': pos}, 506 | params='0..1', enum=[somewhere]) 507 | def lsp_signature_help(arg1, pos, uri, result): 508 | """ 509 | Write signature help by the cursor, info, echo or docsclient. 510 | """ 511 | if not result: 512 | return 513 | where = arg1 or 'cursor' 514 | try: 515 | active = result['signatures'][result['activeSignature']] 516 | pn = result['activeParameter'] 517 | func_label = active.get('label', '') 518 | params = active['parameters'] 519 | label = nice_sig(func_label, params, pn, pos) 520 | except LookupError: 521 | try: 522 | label = pyls_signatureHelp(result, pos) 523 | except LookupError: 524 | if not result.get('signatures'): 525 | label = '' 526 | else: 527 | label = str(result) 528 | return info_somewhere(label, pos, where) 529 | 530 | @client.handler('textDocument/completion', 531 | lambda pos, uri: { 532 | 'textDocument': {'uri': uri}, 533 | 'position': pos}) 534 | def lsp_complete(line, column, timestamp, buffile, completers, result): 535 | """ 536 | Complete at the main cursor. 537 | 538 | Example to force completion at word begin: 539 | 540 | map global insert ':eval -draft %(exec b; lsp-complete)' 541 | 542 | The option lsp_completions is prepended to the completers if missing. 543 | """ 544 | if not result: 545 | return 546 | cs = complete_items(result.get('items', [])) 547 | s = utils.single_quoted(libkak.complete(line, column, timestamp, cs)) 548 | setup = '' 549 | opt = 'option=lsp_completions' 550 | if opt not in completers: 551 | # put ourclient as the first completer if not listed 552 | setup = 'set buffer=' + buffile + ' completers ' 553 | setup += ':'.join([opt] + completers) + '\n' 554 | return setup + 'set buffer=' + buffile + ' lsp_completions ' + s 555 | 556 | @client.handler(params='0..1', enum=[somewhere]) 557 | def lsp_diagnostics(arg1, timestamp, line, buffile, filetype): 558 | """ 559 | Describe diagnostics for the cursor line somewhere 560 | ('cursor', 'info', 'echo' or 'docsclient'.) 561 | 562 | Hook this to NormalIdle if you want: 563 | 564 | hook -group lsp global NormalIdle .* %{ 565 | lsp-diagnostics cursor 566 | } 567 | """ 568 | where = arg1 or 'cursor' 569 | diag = client.diagnostics.get((filetype, buffile), {}) 570 | if line in diag and diag[line]: 571 | min_col = 98765 572 | msgs = [] 573 | for d in diag[line]: 574 | if d['col'] < min_col: 575 | min_col = d['col'] 576 | msgs.append(d['message']) 577 | pos = {'line': line - 1, 'character': min_col - 1} 578 | return info_somewhere('\n'.join(msgs), pos, where) 579 | 580 | @client.handler(params='0..2', enum=[('next', 'prev'), somewhere + ['none']]) 581 | def lsp_diagnostics_jump(arg1, arg2, timestamp, line, buffile, filetype, pipe): 582 | """ 583 | Jump to next or prev diagnostic (relative to the main cursor line) 584 | 585 | Example configuration: 586 | 587 | map global user n ':lsp-diagonstics-jump next cursor' 588 | map global user p ':lsp-diagonstics-jump prev cursor' 589 | """ 590 | direction = arg1 or 'next' 591 | where = arg2 or 'none' 592 | diag = client.diagnostics.get((filetype, buffile)) 593 | if not diag: 594 | libkak._debug('no diagnostics') 595 | return 596 | if timestamp != diag.get('timestamp'): 597 | pipe('lsp-sync') 598 | next_line = None 599 | first_line = None 600 | last_line = None 601 | for other_line in six.iterkeys(diag): 602 | if other_line == 'timestamp': 603 | continue 604 | if not first_line or other_line < first_line: 605 | first_line = other_line 606 | if not last_line or other_line > last_line: 607 | last_line = other_line 608 | if next_line: 609 | if direction == 'prev': 610 | cmp = next_line < other_line < line 611 | else: 612 | cmp = next_line > other_line > line 613 | else: 614 | if direction == 'prev': 615 | cmp = other_line < line 616 | else: 617 | cmp = other_line > line 618 | if cmp: 619 | next_line = other_line 620 | if not next_line and direction == 'prev': 621 | next_line = last_line 622 | if not next_line and direction == 'next': 623 | next_line = first_line 624 | if next_line: 625 | y = next_line 626 | x = diag[y][0]['col'] 627 | end = diag[y][0]['end'] 628 | msg = libkak.select([((y, x), end)]) 629 | if where == 'none': 630 | return msg 631 | else: 632 | info = client.original['lsp_diagnostics'](arg2, timestamp, y, buffile, filetype) 633 | return msg + '\n' + (info or '') 634 | 635 | @client.handler('textDocument/hover', 636 | lambda pos, uri: { 637 | 'textDocument': {'uri': uri}, 638 | 'position': pos}, 639 | params='0..1', enum=[somewhere]) 640 | def lsp_hover(arg1, pos, uri, result): 641 | """ 642 | Display hover information somewhere ('cursor', 'info', 'echo' or 643 | 'docsclient'.) 644 | 645 | Hook this to NormalIdle if you want: 646 | 647 | hook -group lsp global NormalIdle .* %{ 648 | lsp-hover cursor 649 | } 650 | """ 651 | where = arg1 or 'cursor' 652 | label = [] 653 | if not result: 654 | return 655 | contents = result['contents'] 656 | if not isinstance(contents, list): 657 | contents = [contents] 658 | for content in contents: 659 | if isinstance(content, dict) and 'value' in content: 660 | label.append(content['value']) 661 | else: 662 | label.append(content) 663 | label = '\n\n'.join(label) 664 | return info_somewhere(label, pos, where) 665 | 666 | @client.handler('textDocument/references', 667 | lambda arg1, pos, uri: { 668 | 'textDocument': {'uri': uri}, 669 | 'position': pos, 670 | 'context': { 671 | 'includeDeclaration': arg1 != 'false'}}, 672 | params='0..1', enum=[('true', 'false')]) 673 | def lsp_references(arg1, pwd, result): 674 | """ 675 | Find the references to the identifier at the main cursor. 676 | 677 | Takes one argument, whether to include the declaration or not. 678 | (default: true) 679 | """ 680 | m = defaultdict(list) 681 | for loc in result: 682 | m[loc['uri']].append(utils.range(loc['range'])) 683 | if m: 684 | def options(): 685 | for uri, pos in six.iteritems(m): 686 | loc = utils.drop_prefix(utils.uri_to_file(uri), pwd).lstrip('/') or uri 687 | entry = u'{} ({} references)'.format(loc, len(pos)) 688 | yield entry, edit_uri_select(uri, pos) 689 | return libkak.menu(options()) 690 | else: 691 | return 'echo No results.' 692 | 693 | @client.handler('workspace/executeCommand', 694 | lambda args: { 695 | 'command': args[0], 696 | 'arguments': args[1:]}, 697 | params='1..') 698 | def lsp_execute_command(args, result): 699 | """Execute custom command""" 700 | return 'echo ' + utils.single_quoted(str(result)) 701 | 702 | @client.handler('textDocument/definition', 703 | lambda pos, uri: { 704 | 'textDocument': {'uri': uri}, 705 | 'position': pos}) 706 | def lsp_goto_definition(result): 707 | """ 708 | Go to the definition of the identifier at the main cursor. 709 | """ 710 | if not result: 711 | return 'echo -markup {red}No results!' 712 | 713 | if 'uri' in result: 714 | result = [result] 715 | 716 | if not result: 717 | return 'echo -markup {red}No results!' 718 | 719 | def options(): 720 | for loc in result: 721 | p0, p1 = utils.range(loc['range']) 722 | uri = loc['uri'] 723 | action = edit_uri_select(uri, [(p0, p1)]) 724 | line0, _ = p0 725 | yield u'{}:{}'.format(uri, line0), action 726 | return libkak.menu(options()) 727 | 728 | @client.handler('textDocument/rename', 729 | lambda pos, uri, arg1: { 730 | 'textDocument': {'uri': uri}, 731 | 'position': pos, 732 | 'newName': arg1 }, 733 | params='1') 734 | def lsp_rename(result, arg1, pos, uri): 735 | return apply_workspaceedit(result) 736 | 737 | return client 738 | 739 | if __name__ == '__main__': 740 | makeClient().main(sys.argv[1]) 741 | -------------------------------------------------------------------------------- /run_tests.sh: -------------------------------------------------------------------------------- 1 | python2 -m doctest utils.py libkak.py lspc.py && \ 2 | python -m doctest utils.py libkak.py lspc.py && \ 3 | python2 test/mock_ls.py && \ 4 | python test/mock_ls.py 5 | -------------------------------------------------------------------------------- /test/mock_ls.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | import sys 3 | import os 4 | sys.path.append(os.getcwd()) 5 | from multiprocessing import Queue 6 | from pprint import pprint 7 | from threading import Thread 8 | import json 9 | import libkak 10 | import lspc 11 | import subprocess 12 | import time 13 | import utils 14 | 15 | 16 | class MockStdio(object): 17 | r""" 18 | A blocking BytesIO. 19 | 20 | >>> io = MockStdio(Queue()) 21 | >>> io.write('abc\n') 22 | >>> print(io.read(2).decode('utf-8')) 23 | ab 24 | >>> print(io.readline().decode('utf-8')) 25 | c 26 | 27 | >>> io.closed = True 28 | >>> print(io.readline().decode('utf-8')) 29 | 30 | """ 31 | 32 | def __init__(self, q): 33 | self.q = q 34 | self.closed = False 35 | 36 | def write(self, msg): 37 | for c in msg: 38 | self.q.put(chr(c) if isinstance(c, int) else c) 39 | 40 | def flush(self): 41 | pass 42 | 43 | def read(self, n): 44 | cs = [] 45 | for _ in range(n): 46 | while not self.closed: 47 | try: 48 | c = self.q.get(timeout=1) 49 | break 50 | except: 51 | pass 52 | if self.closed: 53 | break 54 | cs.append(c) 55 | return utils.encode(''.join(cs)) 56 | 57 | def readline(self): 58 | cs = [] 59 | while True: 60 | if self.closed: 61 | break 62 | c = self.read(1) 63 | cs.append(c) 64 | if c == b'\n': 65 | break 66 | return b''.join(cs) 67 | 68 | 69 | class MockPopen(object): 70 | 71 | def __init__(self, q_in, q_out): 72 | self.stdin = MockStdio(q_in) 73 | self.stdout = MockStdio(q_out) 74 | 75 | 76 | def listen(p): 77 | line = p.stdin.readline() 78 | header, value = line.split(b":") 79 | assert(header == b"Content-Length") 80 | cl = int(value) 81 | p.stdin.readline() 82 | obj = json.loads(p.stdin.read(cl).decode('utf-8')) 83 | print('Server received: ', json.dumps(obj, indent=2)) 84 | return obj 85 | 86 | 87 | def process(mock, result=None): 88 | """ 89 | Listen for a message to the mock process and make a standard 90 | reply or return result if it is not None. 91 | """ 92 | obj = listen(mock) 93 | method = obj['method'] 94 | if result: 95 | pass 96 | elif method == 'initialize': 97 | result = { 98 | 'capabilities': { 99 | 'signatureHelpProvider': { 100 | 'triggerCharacters': ['(', ','] 101 | }, 102 | 'completionProvider': { 103 | 'triggerCharacters': ['.'] 104 | } 105 | } 106 | } 107 | elif method in ['textDocument/didOpen', 'textDocument/didChange']: 108 | result = None 109 | elif method == 'textDocument/hover': 110 | result = { 111 | 'contents': ['example hover text'] 112 | } 113 | elif method == 'textDocument/signatureHelp': 114 | result = { 115 | 'signatures': [ 116 | { 117 | 'parameters': [ 118 | {'label': 'first: int'}, 119 | {'label': 'second: str'}, 120 | ], 121 | 'label': 'example_function(...): boolean' 122 | } 123 | ], 124 | 'activeSignature': 0, 125 | 'activeParameter': 0 126 | } 127 | elif method == 'textDocument/completion': 128 | items = [ 129 | { 130 | 'label': 'apa', 131 | 'kind': 3, 132 | 'documentation': 'monkey function', 133 | 'detail': 'call the monkey', 134 | }, 135 | { 136 | 'label': 'bepa', 137 | 'kind': 4, 138 | 'documentation': 'monkey constructor', 139 | 'detail': 'construct a monkey', 140 | } 141 | ] 142 | result = {'items': items} 143 | else: 144 | raise RuntimeError('Unknown method: ' + method) 145 | if 'id' in obj: 146 | msg = utils.jsonrpc({ 147 | 'id': obj['id'], 148 | 'result': result 149 | }) 150 | mock.stdout.write(msg) 151 | return obj 152 | 153 | 154 | def setup_test(f): 155 | def decorated(debug=False): 156 | p, q = Queue(), Queue() 157 | 158 | mock = MockPopen(p, q) 159 | kak = libkak.headless(ui='json' if debug else 'dummy', 160 | stdout=subprocess.PIPE) 161 | 162 | @utils.fork(loop=True) 163 | def json_ui_monitor(): 164 | try: 165 | obj = json.loads(kak.stdout.readline()) 166 | pprint(obj) 167 | except: 168 | raise RuntimeError 169 | 170 | def send(s, sync=False): 171 | print('Sending:', s) 172 | libkak.pipe(kak.pid, s, client='unnamed0', sync=sync) 173 | 174 | t = Thread(target=lspc.makeClient().main, args=(kak.pid, {'mock': mock})) 175 | t.daemon = True 176 | t.start() 177 | 178 | time.sleep(0.25) 179 | 180 | send(""" #kak 181 | declare-option str docsclient 182 | set window filetype somefiletype 183 | declare-option str lsp_servers somefiletype:mock 184 | lsp-sync # why does it not trigger on WinSetOption? 185 | """) 186 | 187 | print('listening for initalization...') 188 | obj = process(mock) 189 | assert(obj['method'] == 'initialize') 190 | print('listening for didOpen..') 191 | obj = process(mock) 192 | assert(obj['method'] == 'textDocument/didOpen') 193 | assert(obj['params']['textDocument']['text'] == '\n') 194 | 195 | print('waiting for hooks to be set up...') 196 | time.sleep(0.25) 197 | 198 | f(kak, mock, send) 199 | 200 | send('quit!') 201 | mock.stdout.closed = True 202 | libkak._fifo_cleanup() 203 | kak.wait() 204 | 205 | return decorated 206 | 207 | 208 | @setup_test 209 | def test_hover(kak, mock, send): 210 | send('lsp-hover docsclient') 211 | while True: 212 | obj = process(mock) 213 | if obj['method'] == 'textDocument/hover': 214 | break 215 | time.sleep(0.1) 216 | send('exec \%', sync=True) 217 | call = libkak.Remote.onclient(kak.pid, 'unnamed0') 218 | s = call(lambda selection: selection) 219 | print('hover text:', s) 220 | assert(s == 'example hover text\n') 221 | 222 | 223 | @setup_test 224 | def test_sighelp(kak, mock, send): 225 | send('exec iexample_function(', sync=False) 226 | c = 0 227 | while True: 228 | obj = process(mock) 229 | if obj['method'] == 'textDocument/signatureHelp': 230 | c += 1 231 | if c == 1: 232 | # good, triggered correctly, now onto docsclient 233 | send("exec ':lsp-signature-help docsclient'", sync=False) 234 | if c == 2: 235 | break 236 | time.sleep(0.1) 237 | send('exec \%', sync=True) 238 | call = libkak.Remote.onclient(kak.pid, 'unnamed0') 239 | s = call(lambda selection: selection) 240 | print('sighelp:', s) 241 | assert(s == 'example_function(*first: int*, second: str): boolean\n') 242 | 243 | 244 | @setup_test 245 | def test_completion(kak, mock, send): 246 | q = Queue() 247 | 248 | @libkak.Remote.hook(kak.pid, 'buffer', 'InsertCompletionShow', 249 | client='unnamed0', sync_setup=True) 250 | def hook(pipe): 251 | pipe("exec '\%'") 252 | q.put(()) 253 | send('exec itest.') 254 | 255 | print('listening...') 256 | obj = process(mock) 257 | pprint(obj) 258 | assert(obj['method'] == 'textDocument/didChange') 259 | assert(obj['params']['contentChanges'][0]['text'] == 'test.\n') 260 | obj = process(mock) 261 | assert(obj['method'] == 'textDocument/completion') 262 | assert(obj['params']['position'] == {'line': 0, 'character': 5}) 263 | q.get() 264 | call = libkak.Remote.onclient(kak.pid, 'unnamed0') 265 | s = call(lambda selection: selection) 266 | print('final selection:', s) 267 | assert(s == 'test.apa\n') 268 | 269 | 270 | @setup_test 271 | def test_diagnostics(kak, mock, send): 272 | send('exec 7oabcdefghijklmnopqrstuvwxyzgg') 273 | 274 | msg = utils.jsonrpc({ 275 | 'method': 'textDocument/publishDiagnostics', 276 | 'params': { 277 | 'uri': 'file://*scratch*', 278 | 'diagnostics': [{ 279 | 'message': 'line ' + str(y), 280 | 'range': { 281 | 'start': { 282 | 'line': y-1, 283 | 'character': y*2-1 284 | }, 285 | 'end': { 286 | 'line': y-1, 287 | 'character': y*3-1 288 | } 289 | } 290 | } for y in [2, 4, 6]] 291 | } 292 | }) 293 | mock.stdout.write(msg) 294 | time.sleep(0.1) 295 | first = True 296 | for y in [2,4,6,2,4]: 297 | send('lsp-diagnostics-jump next') # 2 4 6 2 4 298 | if first: 299 | first = False 300 | print('listening...') 301 | obj = process(mock) 302 | pprint(obj) 303 | assert(obj['method'] == 'textDocument/didChange') 304 | assert(obj['params']['contentChanges'][0]['text'] == '\n' + 'abcdefghijklmnopqrstuvwxyz\n' * 7) 305 | time.sleep(0.1) 306 | call = libkak.Remote.onclient(kak.pid, 'unnamed0') 307 | d = call(lambda selection_desc: selection_desc) 308 | print('selection_desc:', d) 309 | assert(d == ((y,2*y),(y,3*y-1))) # end point exclusive according to protocol.md 310 | 311 | send('lsp-diagnostics-jump prev') # 2 312 | time.sleep(0.1) 313 | send('lsp-diagnostics docsclient') 314 | time.sleep(0.3) 315 | send('exec x') 316 | time.sleep(0.3) 317 | call = libkak.Remote.onclient(kak.pid, 'unnamed0') 318 | s = call(lambda selection: selection) 319 | print('final selection:', s) 320 | assert(s == 'line 2\n') 321 | 322 | 323 | if __name__ == '__main__': 324 | import doctest 325 | doctest.testmod() 326 | debug = '-v' in sys.argv 327 | test_completion(debug) 328 | test_sighelp(debug) 329 | test_hover(debug) 330 | test_diagnostics(debug) 331 | -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from threading import Thread 4 | import six 5 | import inspect 6 | import json 7 | 8 | 9 | def drop_prefix(s, prefix): 10 | """ 11 | If s starts with prefix, drop it, otherwise return s. 12 | 13 | >>> print(drop_prefix('apabepa', 'ap')) 14 | abepa 15 | >>> print(drop_prefix('apabepa', 'cepa')) 16 | apabepa 17 | """ 18 | if s.startswith(prefix): 19 | return s[len(prefix):] 20 | else: 21 | return s 22 | 23 | 24 | def uri_to_file(uri): 25 | """ 26 | >>> print(uri_to_file('file:///home/user/proj/%40types.js')) 27 | /home/user/proj/@types.js 28 | >>> print(uri_to_file('http://example.com')) 29 | None 30 | """ 31 | if uri.startswith('file://'): 32 | f = drop_prefix(uri, 'file://') 33 | return six.moves.urllib.parse.unquote(f) 34 | else: 35 | return None 36 | 37 | 38 | def range(r): 39 | y0 = int(r['start']['line']) + 1 40 | x0 = int(r['start']['character']) + 1 41 | y1 = int(r['end']['line']) + 1 42 | x1 = int(r['end']['character']) 43 | return ((y0, x0), (y1, x1)) 44 | 45 | def jsonrpc(obj): 46 | obj['jsonrpc'] = '2.0' 47 | msg = json.dumps(obj) 48 | msg = u"Content-Length: {0}\r\n\r\n{1}".format(len(msg), msg) 49 | return msg.encode('utf-8') 50 | 51 | 52 | def deindent(s): 53 | """ 54 | >>> print(deindent(''' 55 | ... bepa 56 | ... apa 57 | ... 58 | ... cepa 59 | ... ''')) 60 | 61 | bepa 62 | apa 63 | 64 | cepa 65 | 66 | """ 67 | lines = s.split('\n') 68 | 69 | chop = 98765 70 | for line in lines: 71 | if line.strip(): 72 | m = len(line) - len(line.lstrip()) 73 | if m < chop: 74 | chop = m 75 | 76 | return '\n'.join(line[chop:] for line in lines) 77 | 78 | 79 | def fork(loop=False): 80 | def decorate(f): 81 | def target(): 82 | try: 83 | while True: 84 | f() 85 | if not loop: 86 | break 87 | except RuntimeError: 88 | pass 89 | thread = Thread(target=target) 90 | thread.daemonize = True 91 | thread.start() 92 | return decorate 93 | 94 | 95 | def join(words, sep=u' '): 96 | """ 97 | Join strings or bytes into a string, returning a string. 98 | """ 99 | return decode(sep).join(decode(w) for w in words) 100 | 101 | 102 | def encode(s): 103 | """ 104 | Encode a unicode string into bytes. 105 | """ 106 | if isinstance(s, six.binary_type): 107 | return s 108 | elif isinstance(s, six.string_types): 109 | return s.encode('utf-8') 110 | else: 111 | raise ValueError('Expected string or bytes') 112 | 113 | 114 | def decode(s): 115 | """ 116 | Decode into a string (a unicode object). 117 | """ 118 | if isinstance(s, six.binary_type): 119 | return s.decode('utf-8') 120 | elif isinstance(s, six.string_types): 121 | return s 122 | else: 123 | raise ValueError('Expected string or bytes') 124 | 125 | 126 | def single_quote_escape(string): 127 | """ 128 | Backslash-escape ' and \ in Kakoune style . 129 | """ 130 | return string.replace("\\'", "\\\\'").replace("'", "\\'") 131 | 132 | 133 | def single_quoted(string): 134 | u""" 135 | The string wrapped in single quotes and escaped in Kakoune style. 136 | 137 | https://github.com/mawww/kakoune/issues/1049 138 | 139 | >>> print(single_quoted(u"i'ié")) 140 | 'i\\'ié' 141 | """ 142 | return u"'" + single_quote_escape(string) + u"'" 143 | 144 | 145 | def backslash_escape(cs, s): 146 | for c in cs: 147 | s = s.replace(c, "\\" + c) 148 | return s 149 | 150 | 151 | def argnames(f): 152 | """ 153 | >>> argnames(lambda x, y, *zs, **kws: None) 154 | ['x', 'y'] 155 | """ 156 | return inspect.getargspec(f).args 157 | 158 | 159 | def safe_kwcall(f, d): 160 | """ 161 | >>> safe_kwcall(lambda x: x, dict(x=2, y=3)) 162 | 2 163 | """ 164 | return f(*(d[k] for k in argnames(f))) 165 | 166 | 167 | def noop(*args, **kwargs): 168 | """ 169 | Do nothing! 170 | 171 | >>> noop(1, 2, 3) 172 | """ 173 | return 174 | --------------------------------------------------------------------------------