diff --git a/.coafile b/.coafile index 107518c..9a54972 100644 --- a/.coafile +++ b/.coafile @@ -1,10 +1,10 @@ -[Default] -files = *.py, coala_langserver/*.py, tests/**/*.py +[all] +files = coalals/*.py, coalals/**/*.py, tests/**/*.py max_line_length = 79 use_spaces = True -[python] +[all.python] # Patches may conflict with autopep8 so putting them in own section so they # will be executed sequentially; also we need the LineLengthBear to double # check the line length because PEP8Bear sometimes isn't able to correct the @@ -13,26 +13,26 @@ bears = SpaceConsistencyBear, QuotesBear language = python preferred_quotation = ' -[autopep8] +[all.autopep8] bears = PEP8Bear, PycodestyleBear, PyDocStyleBear pydocstyle_ignore = D100, D101, D102, D103, D104, D107, - D200, D203, D205, D209, D212, D213, D400 + D200, D203, D205, D209, D212, D213, D400, D105, D403, -[linelength] # Sometimes autopep8 makes too long lines, need to check after! +[all.linelength] # Sometimes autopep8 makes too long lines, need to check after! bears = LineLengthBear ignore_length_regex = ^.*https?:// -[commit] +[all.commit] bears = GitCommitBear shortlog_trailing_period = False shortlog_regex = ([^:]*|[^:]+[^ ]: [A-Z0-9*].*) -[LineCounting] +[all.LineCounting] enabled = False bears = LineCountBear max_lines_per_file = 1000 -[TODOS] +[all.TODOS] enabled = False bears = KeywordBear language = python3 @@ -42,6 +42,6 @@ language = python3 ci_keywords, keywords = \#TODO, \# TODO, \#FIXME, \# FIXME cs_keywords = -[yml] +[all.yml] bears = YAMLLintBear files = *.yml diff --git a/.codecov.yml b/.codecov.yml index 9d955c5..889797c 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -6,4 +6,4 @@ coverage: project: default: enabled: true - target: 95% + target: 100% diff --git a/.coveragerc b/.coveragerc index 9e73f79..444e6f2 100644 --- a/.coveragerc +++ b/.coveragerc @@ -2,7 +2,7 @@ branch = True source = . omit = - coala-langserver.py + coalals/__main__.py tests/* [report] diff --git a/.gitignore b/.gitignore index c4cad02..f14dd8c 100644 --- a/.gitignore +++ b/.gitignore @@ -92,3 +92,6 @@ ENV/ # Rope project settings .ropeproject + +# vscode directory +.vscode/ diff --git a/.travis.yml b/.travis.yml index 4a6b1ea..48ae5f8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -27,28 +27,14 @@ before_install: fi install: - # beheve is a dev dependeny, so not in requirements.txt. - - pip install behave - pip install -r ./requirements.txt - # codecov is a code coverage tool. - pip install codecov - - cd ./vscode-client - - npm install - - mkdir ./out - - npm run vscode:prepublish - cd - > /dev/null script: - > - # https://github.com/coala/coala-bears/issues/1037 - sed -i.bak '/bears = GitCommitBear/d' .coafile - # Server side tests. - - coverage run $(which behave) ./tests/server.features - - coverage run -a -m unittest discover -s tests - # Frontend tests. -# - cd ./vscode-client -# - npm test -# - cd - > /dev/null + # - coverage run -a -m unittest discover -s tests notifications: email: false diff --git a/README.md b/README.md index c7afc71..a0dc489 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,11 @@ -# coala-vs-code +# coala-ls -[![Build Status](https://travis-ci.org/coala/coala-vs-code.svg?branch=master)](https://travis-ci.org/coala/coala-vs-code) -[![codecov](https://codecov.io/gh/coala/coala-vs-code/branch/master/graph/badge.svg)](https://codecov.io/gh/coala/coala-vs-code) +A coala language server based on [Language Server Protocol (LSP)](https://github.com/Microsoft/language-server-protocol/blob/master/protocol.md). Python versions 3.x is supported. -A visual studio code plugin working via [Language Server Protocol (LSP)](https://github.com/Microsoft/language-server-protocol/blob/master/protocol.md).Python versions 3.x is supported. - -## Feature preview - -![](./docs/images/demo.gif) ## Setting up your dev environment, coding, and debugging -You'll need python version 3.5 or greater, run `pip3 install -r requirements.txt` to install the requirements, and run `python3 langserver-python.py --mode=tcp --addr=2087` to start a local languager server listening at port 2087. +You'll need python version 3.5 or greater, run `pip3 install -r requirements.txt` to install the requirements, and run `python3 -m coalals --mode=tcp --addr=2087` to start a local languager server listening at port 2087. Then you should update the `./vscode-client/src/extension.ts` to make client in TCP mode. @@ -26,10 +20,7 @@ export function activate(context: ExtensionContext) { To try it in [Visual Studio Code](https://code.visualstudio.com), open ./vscode-client in VS Code and turn to debug view, launch the extension. -## Known bugs - -* [Language server restarts when `didSave` requests come](https://github.com/coala/coala-vs-code/issues/7) - ## Reference * [python-langserver](https://github.com/sourcegraph/python-langserver) +* [python-language-server](http://github.com/palantir/python-language-server) diff --git a/coala-langserver.py b/coala-langserver.py deleted file mode 100644 index 7e77ec6..0000000 --- a/coala-langserver.py +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/local/bin/python3 - -import traceback -from coala_langserver import langserver - -while True: - try: - langserver.main() - except Exception as e: - tb = traceback.format_exc() - print('FATAL ERROR: {} {}'.format(e, tb)) diff --git a/coala-langserver.sh b/coala-ls.sh similarity index 62% rename from coala-langserver.sh rename to coala-ls.sh index a88d46d..3e05ab7 100755 --- a/coala-langserver.sh +++ b/coala-ls.sh @@ -1,5 +1,5 @@ #!/bin/sh cd "$(dirname "$0")" > /dev/null -exec python3 ./coala-langserver.py +exec python3 -m coalals cd - /dev/null diff --git a/coala_langserver/coalashim.py b/coala_langserver/coalashim.py deleted file mode 100644 index f04f2a3..0000000 --- a/coala_langserver/coalashim.py +++ /dev/null @@ -1,30 +0,0 @@ -import sys -import os -import io -from contextlib import redirect_stdout - -from coalib import coala - -from .log import log - - -def run_coala_with_specific_file(working_dir, file): - sys.argv = ['', '--json', '--find-config', '--limit-files', file] - if working_dir is None: - working_dir = '.' - os.chdir(working_dir) - f = io.StringIO() - with redirect_stdout(f): - retval = coala.main() - output = None - if retval == 1: - output = f.getvalue() - if output: - log('Output =', output) - else: - log('No results for the file') - elif retval == 0: - log('No issues found') - else: - log('Exited with:', retval) - return output diff --git a/coala_langserver/diagnostic.py b/coala_langserver/diagnostic.py deleted file mode 100644 index 1e6150e..0000000 --- a/coala_langserver/diagnostic.py +++ /dev/null @@ -1,56 +0,0 @@ -import json - - -def output_to_diagnostics(output): - """ - Turn output to diagnstics. - """ - if output is None: - return None - output_json = json.loads(output)['results'] - res = [] - for key, problems in output_json.items(): - section = key - for problem in problems: - """ - Transform RESULT_SEVERITY of coala into DiagnosticSeverity of LSP - coala: INFO = 0, NORMAL = 1, MAJOR = 2 - LSP: Error = 1, Warning = 2, Information = 3, Hint = 4 - """ - severity = 3 - problem['severity'] - message = problem['message'] - origin = problem['origin'] - real_message = '[{}] {}: {}'.format(section, origin, message) - for code in problem['affected_code']: - """ - Line position and character offset should be zero-based - according to LSP, but row and column positions of coala - are None or one-based number. - coala uses None for convenience. None for column means the - whole line while None for line means the whole file. - """ - def convert_offset(x): return x - 1 if x else x - start_line = convert_offset(code['start']['line']) - start_char = convert_offset(code['start']['column']) - end_line = convert_offset(code['end']['line']) - end_char = convert_offset(code['end']['column']) - if start_char is None or end_char is None: - start_char = 0 - end_line = start_line + 1 - end_char = 0 - res.append({ - 'severity': severity, - 'range': { - 'start': { - 'line': start_line, - 'character': start_char - }, - 'end': { - 'line': end_line, - 'character': end_char - } - }, - 'source': 'coala', - 'message': real_message - }) - return res diff --git a/coala_langserver/langserver.py b/coala_langserver/langserver.py deleted file mode 100644 index 7ea9bd4..0000000 --- a/coala_langserver/langserver.py +++ /dev/null @@ -1,156 +0,0 @@ -import sys -import argparse -import socketserver -import traceback - -from pyls.jsonrpc.endpoint import Endpoint -from pyls.jsonrpc.dispatchers import MethodDispatcher -from pyls.jsonrpc.streams import JsonRpcStreamReader -from pyls.jsonrpc.streams import JsonRpcStreamWriter -from coala_utils.decorators import enforce_signature -from .log import log -from .coalashim import run_coala_with_specific_file -from .uri import path_from_uri -from .diagnostic import output_to_diagnostics - - -class _StreamHandlerWrapper(socketserver.StreamRequestHandler, object): - """ - A wrapper class that is used to construct a custom handler class. - """ - - delegate = None - - def setup(self): - super(_StreamHandlerWrapper, self).setup() - self.delegate = self.DELEGATE_CLASS(self.rfile, self.wfile) - - def handle(self): - self.delegate.start() - - -class LangServer(MethodDispatcher): - """ - Language server for coala base on JSON RPC. - """ - - def __init__(self, rx, tx): - self.root_path = None - self._jsonrpc_stream_reader = JsonRpcStreamReader(rx) - self._jsonrpc_stream_writer = JsonRpcStreamWriter(tx) - self._endpoint = Endpoint(self, self._jsonrpc_stream_writer.write) - self._dispatchers = [] - self._shutdown = False - - def start(self): - self._jsonrpc_stream_reader.listen(self._endpoint.consume) - - def m_initialize(self, **params): - """ - Serve for the initialization request. - """ - # Notice that the root_path could be None. - if 'rootUri' in params: - self.root_path = path_from_uri(params['rootUri']) - elif 'rootPath' in params: - self.root_path = path_from_uri(params['rootPath']) - return { - 'capabilities': { - 'textDocumentSync': 1 - } - } - - def m_text_document__did_save(self, **params): - """ - Serve for did_change request. - """ - uri = params['textDocument']['uri'] - path = path_from_uri(uri) - diagnostics = output_to_diagnostics( - run_coala_with_specific_file(self.root_path, path)) - self.send_diagnostics(path, diagnostics) - - def m_shutdown(self, **_kwargs): - self._shutdown = True - - # TODO: Support did_change and did_change_watched_files. - # def serve_change(self, request): - # '""Serve for the request of documentation changed.""' - # params = request['params'] - # uri = params['textDocument']['uri'] - # path = path_from_uri(uri) - # diagnostics = output_to_diagnostics( - # run_coala_with_specific_file(self.root_path, path)) - # self.send_diagnostics(path, diagnostics) - # return None - # - # def serve_did_change_watched_files(self, request): - # '""Serve for thr workspace/didChangeWatchedFiles request.""' - # changes = request['changes'] - # for fileEvent in changes: - # uri = fileEvent['uri'] - # path = path_from_uri(uri) - # diagnostics = output_to_diagnostics( - # run_coala_with_specific_file(self.root_path, path)) - # self.send_diagnostics(path, diagnostics) - - def send_diagnostics(self, path, diagnostics): - _diagnostics = [] - if diagnostics is not None: - _diagnostics = diagnostics - params = { - 'uri': 'file://{0}'.format(path), - 'diagnostics': _diagnostics, - } - self._endpoint.notify('textDocument/publishDiagnostics', params=params) - - -@enforce_signature -def start_tcp_lang_server(handler_class: LangServer, bind_addr, port): - # Construct a custom wrapper class around the user's handler_class - wrapper_class = type( - handler_class.__name__ + 'Handler', - (_StreamHandlerWrapper,), - {'DELEGATE_CLASS': handler_class}, - ) - - try: - server = socketserver.TCPServer((bind_addr, port), wrapper_class) - except Exception as e: - log('Fatal Exception: {}'.format(e)) - sys.exit(1) - - log('Serving {} on ({}, {})'.format( - handler_class.__name__, bind_addr, port)) - try: - server.serve_forever() - finally: - log('Shutting down') - server.server_close() - - -@enforce_signature -def start_io_lang_server(handler_class: LangServer, rstream, wstream): - log('Starting {} IO language server'.format(handler_class.__name__)) - server = handler_class(rstream, wstream) - server.start() - - -def main(): - parser = argparse.ArgumentParser(description='') - parser.add_argument('--mode', default='stdio', - help='communication (stdio|tcp)') - parser.add_argument('--addr', default=2087, - help='server listen (tcp)', type=int) - - args = parser.parse_args() - - if args.mode == 'stdio': - start_io_lang_server(LangServer, sys.stdin.buffer, sys.stdout.buffer) - elif args.mode == 'tcp': - host, addr = '0.0.0.0', args.addr - start_tcp_lang_server(LangServer, host, addr) - - -if __name__ == '__main__': - main() diff --git a/coala_langserver/log.py b/coala_langserver/log.py deleted file mode 100644 index 924f0a5..0000000 --- a/coala_langserver/log.py +++ /dev/null @@ -1,6 +0,0 @@ -import sys - - -def log(*args, **kwargs): - print(*args, file=sys.stderr, **kwargs) - sys.stderr.flush() diff --git a/coala_langserver/uri.py b/coala_langserver/uri.py deleted file mode 100644 index 977135e..0000000 --- a/coala_langserver/uri.py +++ /dev/null @@ -1,18 +0,0 @@ -import os - - -def path_from_uri(uri): - """ - Get the path from JSON RPC initialization request. - """ - if not uri.startswith('file://'): - return uri - _, path = uri.split('file://', 1) - return path - - -def dir_from_uri(uri): - """ - Get the directory name from the path. - """ - return os.path.dirname(path_from_uri(uri)) diff --git a/coala_langserver/__init__.py b/coalals/__init__.py similarity index 100% rename from coala_langserver/__init__.py rename to coalals/__init__.py diff --git a/coalals/__main__.py b/coalals/__main__.py new file mode 100644 index 0000000..8b9a74a --- /dev/null +++ b/coalals/__main__.py @@ -0,0 +1,9 @@ +from sys import argv, stdout +from .main import main +from .utils.log import configure_logger + +import logging +configure_logger() + +if __name__ == '__main__': + main() diff --git a/coalals/coaconfig/__init__.py b/coalals/coaconfig/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/coalals/concurrency.py b/coalals/concurrency.py new file mode 100644 index 0000000..595232c --- /dev/null +++ b/coalals/concurrency.py @@ -0,0 +1,122 @@ +from concurrent.futures import ProcessPoolExecutor + +from coala_utils.decorators import enforce_signature +from .utils.wrappers import func_wrapper + +import logging +logger = logging.getLogger(__name__) + + +class JobTracker: + """ + JobTracker helps to keep track of running jobs and + function as an advanced counter. It is lazy and will + only update its internal state when requested for + information about it. JobTracker instances should always + live on the main thread. Hence should never go out of sync. + """ + + @staticmethod + def kill_job(job): + # job is a Future, cancel() only requests and + # does not currently guarantee cancellation. + # TODO Add a reliable cancellation mechanism + # (https://kutt.it/MyF1AZ) + return job.cancel() + + @staticmethod + def is_active(job): + return not job.done() + + def __init__(self, max_jobs=1): + if max_jobs < 1: + raise ValueError() + + self._max_jobs = max_jobs + # actually contains the Future + # instances from scheduled jobs + self._jobs = [] + + def refresh_jobs(self): + self._jobs = list(filter( + lambda job: JobTracker.is_active(job), self._jobs)) + + def __len__(self): + self.refresh_jobs() + return len(self._jobs) + + def has_slots(self): + self.refresh_jobs() + return len(self._jobs) < self._max_jobs + + def force_free_slots(self): + if self.has_slots(): + return True + + count = 1 + len(self._jobs) - self._max_jobs + for job in self._jobs[:count]: + if not JobTracker.kill_job(job): + return False + + return True + + def prepare_slot(self, force=False): + """ + JobTracker is not responsible to schedule, + monitor or manage the jobs in any manner, although + it can send a pre-empt signal to running jobs. + It only keeps a running count and indicates if + resources with respect to max allocation are + available to accept incoming request. Hence the + prepare_slot() should be called before actual + job allocation happens to check for resources. + """ + if not self.has_slots(): + if force is True: + return self.force_free_slots() + else: + return False + + return True + + def add(self, job): + # Evaluates lazily and does not complain about + # the overflow. prepare_slot() should be used. + + # TODO self._jobs should be a dict and should + # map from filename to job object. That way we + # can prevent multiple requests fighting to run + # on the same source file. + # take caution before adding + self._jobs.append(job) + + +class TrackedProcessPool: + """ + Abstracts the integration of ProcessPoolExec, + JobTracker and func_wrapper. + """ + + def __init__(self, max_jobs=1, max_workers=1): + self._job_tracker = JobTracker(max_jobs) + self._process_pool = ProcessPoolExecutor(max_workers=max_workers) + + @enforce_signature + def exec_func(self, + func, + params: (list, tuple) = [], + kparams: dict = {}, + force=False): + # TODO Add a meta information carrying mechanism + # so the callbacks can get some context. + if not self._job_tracker.prepare_slot(force): + return False + + future = self._process_pool.submit( + func_wrapper, func, *params, **kparams) + + self._job_tracker.add(future) + return future + + def shutdown(self, wait=True): + self._process_pool.shutdown(wait) diff --git a/coalals/interface.py b/coalals/interface.py new file mode 100644 index 0000000..f80de5f --- /dev/null +++ b/coalals/interface.py @@ -0,0 +1,77 @@ +import sys +from os import chdir +from json import dumps +from io import StringIO +from contextlib import redirect_stdout + +from coalib import coala +from .concurrency import TrackedProcessPool +from .utils.log import configure_logger, reset_logger + +import logging +logger = logging.getLogger(__name__) + + +class coalaWrapper: + """ + Provides an abstract interaction layer to coala + to perform the actual analysis. + """ + + def __init__(self, max_jobs=1, max_workers=1): + self._tracked_pool = TrackedProcessPool( + max_jobs=max_jobs, max_workers=max_workers) + + @staticmethod + def _run_coala(): + stream = StringIO() + with redirect_stdout(stream): + return (stream, coala.main()) + + @staticmethod + def _get_op_from_coala(stream, retval): + output = None + if retval == 1: + output = stream.getvalue() + if output: + logger.debug('Output: %s', output) + else: + logger.debug('No results for the file') + elif retval == 0: + logger.debug('No issues found') + else: + logger.debug('Exited with: %s', retval) + + return output or dumps({'results': {}}) + + @staticmethod + def analyse_file(file_proxy): + logger.info('Running analysis on %s', file_proxy.filename) + + sys.argv = ['', '--json', '--find-config', + '--limit-files', file_proxy.filename] + + workspace = file_proxy.workspace + if workspace is None: + workspace = '.' + chdir(workspace) + + stream, retval = coalaWrapper._run_coala() + return coalaWrapper._get_op_from_coala(stream, retval) + + def p_analyse_file(self, file_proxy, force=False, **kargs): + """ + It is a concurrent version of coalaWrapper.analyse_file(). + force indicates whether the request should pre-empt running + cycles. + """ + result = self._tracked_pool.exec_func( + coalaWrapper.analyse_file, (file_proxy,), kargs, force=force) + + if result is False: + logging.debug('Failed p_analysis_file() on %s', file_proxy) + + return result + + def close(self): + self._tracked_pool.shutdown() diff --git a/coalals/langserver.py b/coalals/langserver.py new file mode 100644 index 0000000..89a05c9 --- /dev/null +++ b/coalals/langserver.py @@ -0,0 +1,175 @@ +from jsonrpc.endpoint import Endpoint +from jsonrpc.dispatchers import MethodDispatcher +from jsonrpc.streams import JsonRpcStreamWriter, JsonRpcStreamReader + +from .results import Diagnostics +from .interface import coalaWrapper +from .utils.files import UriUtils, FileProxy, FileProxyMap + +import logging +logger = logging.getLogger(__name__) + + +class LangServer(MethodDispatcher): + + def __init__(self, rx, tx): + self.root_path = None + self._dispatchers = [] + self._shutdown = False + + self._jsonrpc_stream_reader = JsonRpcStreamReader(rx) + self._jsonrpc_stream_writer = JsonRpcStreamWriter(tx) + self._endpoint = Endpoint(self, self._jsonrpc_stream_writer.write) + self._proxy_map = FileProxyMap() + + # max_jobs is strict and a new job can only be submitted by pre-empting + # an older job or submitting later. No queuing is supported. + self._coala = coalaWrapper(max_jobs=2, max_workers=2) + + self._capabilities = { + 'capabilities': { + 'textDocumentSync': 1, + } + } + + def start(self): # pragma: no cover + self._jsonrpc_stream_reader.listen(self._endpoint.consume) + + def m_initialize(self, **params): + logger.info('Serving initialize request') + + # Notice that the root_path could be None. + if 'rootUri' in params: + self.root_path = UriUtils.path_from_uri(params['rootUri']) + elif 'rootPath' in params: # pragma: no cover + self.root_path = UriUtils.path_from_uri(params['rootPath']) + + return self._capabilities + + def local_p_analyse_file(self, fileproxy, force=False): + """ + Schedule concurrent analysis cycle and handles + the resolved future. The diagnostics are published + from the callback. + """ + filename = fileproxy.filename + + result = self._coala.p_analyse_file(fileproxy, force=force) + if result is False: + logging.debug('Failed analysis on %s', fileproxy) + return + + # Always called on this thread + def _handle_diagnostics(future): + # TODO Find a better method to deal with + # failure cases. + if future.cancelled(): + logger.debug('Cancelled diagnostics on %s', filename) + return + + if future.exception(): + logger.debug('Exception during analysis: %s', + future.exception()) + return + + coala_json = future.result() + diagnostics = Diagnostics.from_coala_json(coala_json) + + self.send_diagnostics(filename, diagnostics) + + result.add_done_callback(_handle_diagnostics) + + def _text_document_to_name(self, text_document): + uri = text_document['uri'] + return UriUtils.path_from_uri(uri) + + def m_text_document__did_open(self, **params): + logger.info('Reacting to didOpen notification') + + text_document = params['textDocument'] + filename = self._text_document_to_name(text_document) + + # didOpen can create the file proxy from + # the params passed to it + contents = text_document['text'] + version = text_document['version'] + proxy = FileProxy(filename) + proxy.replace(contents, version) + self._proxy_map.add(proxy, replace=True) + + self.local_p_analyse_file(proxy, True) + + def m_text_document__did_save(self, **params): + logger.info('Reacting to didSave notification') + + text_document = params['textDocument'] + filename = self._text_document_to_name(text_document) + + # If the file does not exist in the proxy map + # discard the request, it should didOpen first + proxy = self._proxy_map.get(filename) + if proxy is None: + return + + text_document = params['textDocument'] + self.local_p_analyse_file(proxy, True) + + def m_text_document__did_change(self, **params): + """ + Handle the file updates and syncs the file proxy + """ + logger.info('Reacting to didChange notification') + text_document = params['textDocument'] + content_changes = params['contentChanges'] + + filename = self._text_document_to_name(text_document) + proxy = self._proxy_map.get(filename) + + # Send a didOpen first + if proxy is None: + return + + # update if range and rangeLength are present + # in the contentChanges dict else replace + if ('range' not in content_changes[0] and + 'rangeLength' not in content_changes[0]): + + version = text_document['version'] + content = content_changes[0]['text'] + + if proxy.replace(content, version): + logger.info( + 'Replaced proxy content to version %s', version) + + # FIXME Add a way to handle the range updates mechanism + # i.e resolve diffs and construct the text, the diff + # handling mechanism should be handled in FileProxy's + # update method + + def m_text_document__did_close(self, **params): + logger.info('Reacting to didClose notification') + + text_document = params['textDocument'] + filename = self._text_document_to_name(text_document) + + # just remove the proxy object + self._proxy_map.remove(filename) + + # TODO Add a mechanism to send a pre-empt signal to running + # analysis cycles on the file being closed. + + def m_shutdown(self, **_kwargs): + self._shutdown = True + + def send_diagnostics(self, path, diagnostics): + warnings = diagnostics.warnings() + logger.info('Publishing %s diagnostic messages', len(warnings)) + + params = { + 'uri': UriUtils.file_to_uri(path), + 'diagnostics': warnings, + } + + self._endpoint.notify( + 'textDocument/publishDiagnostics', + params=params) diff --git a/coalals/main.py b/coalals/main.py new file mode 100644 index 0000000..ffc230a --- /dev/null +++ b/coalals/main.py @@ -0,0 +1,56 @@ +import sys +from socketserver import TCPServer +from argparse import ArgumentParser + +from coala_utils.decorators import enforce_signature +from .utils.wrappers import StreamHandlerWrapper +from .langserver import LangServer + +import logging +logger = logging.getLogger(__name__) + + +@enforce_signature +def start_tcp_lang_server(handler_class: LangServer, bind_addr, port): + wrapper_class = type( + handler_class.__name__ + 'Handler', + (StreamHandlerWrapper,), + {'DELEGATE_CLASS': handler_class, }, + ) + + try: + server = TCPServer((bind_addr, port), wrapper_class) + except Exception as e: + logger.fatal('Fatal Exception: %s', e) + sys.exit(1) + + logger.info('Serving %s on (%s, %s)', + handler_class.__name__, bind_addr, port) + + try: + server.serve_forever() + finally: + logger.info('Shutting down') + server.server_close() + + +@enforce_signature +def start_io_lang_server(handler_class: LangServer, rstream, wstream): + logger.info('Starting %s IO language server', handler_class.__name__) + server = handler_class(rstream, wstream) + server.start() + + +def main(): + parser = ArgumentParser(description='') + parser.add_argument('--mode', default='stdio', + help='communication (stdio|tcp)') + parser.add_argument('--addr', default=2087, + help='server listen (tcp)', type=int) + args = parser.parse_args() + + if args.mode == 'stdio': + start_io_lang_server(LangServer, sys.stdin.buffer, sys.stdout.buffer) + elif args.mode == 'tcp': + host, addr = '0.0.0.0', args.addr + start_tcp_lang_server(LangServer, host, addr) diff --git a/coalals/results.py b/coalals/results.py new file mode 100644 index 0000000..508297d --- /dev/null +++ b/coalals/results.py @@ -0,0 +1,75 @@ +from json import loads + +import logging +logger = logging.getLogger(__name__) + + +class Diagnostics: + + @classmethod + def from_coala_json(cls, json_op): + coala_op = loads(json_op)['results'] + warnings, fixes = [], [] + + def convert_offset(x): + return x - 1 if x else x + + for section, coala_warnings in coala_op.items(): + for warning in coala_warnings: + """ + Transform RESULT_SEVERITY of coala to DiagnosticSeverity of LSP + coala: INFO = 0, NORMAL = 1, MAJOR = 2 + LSP: Error = 1, Warning = 2, Information = 3, Hint = 4 + """ + severity = 3 - warning['severity'] + message = warning['message'] + origin = warning['origin'] + full_message = '[{}] {}: {}'.format(section, origin, message) + + # TODO Handle results for multiple files + for code in warning['affected_code']: + start_line = convert_offset(code['start']['line']) + start_char = convert_offset(code['start']['column']) + end_line = convert_offset(code['end']['line']) + end_char = convert_offset(code['end']['column']) + + if start_char is None or end_char is None: + start_char, end_char = 0, 0 + end_line = start_line + 1 + + warnings.append({ + 'severity': severity, + 'range': { + 'start': { + 'line': start_line, + 'character': start_char, + }, + 'end': { + 'line': end_line, + 'character': end_char, + }, + }, + 'source': 'coala', + 'message': full_message, + }) + + # TODO Handle results for multiple files + # and also figure out a way to resolve + # overlapping patches. + + # for file, diff in warning['diffs'].items(): + # for parsed_diff in parse_patch(diff): + # pass + + logger.debug(warnings) + return cls(warnings, fixes=fixes) + + def __init__(self, warnings=[], fixes=[]): + self._warnings = warnings + self._fixes = fixes + + def warnings(self): + return self._warnings + + def fixes(self): + return self._fixes diff --git a/coalals/utils/__init__.py b/coalals/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/coalals/utils/entities.py b/coalals/utils/entities.py new file mode 100644 index 0000000..6919b76 --- /dev/null +++ b/coalals/utils/entities.py @@ -0,0 +1,8 @@ +class LSPEntity: + + @classmethod + def entity_name(cls): + return cls.__name__ + + def __json__(self): + raise NotImplementedError() diff --git a/coalals/utils/files.py b/coalals/utils/files.py new file mode 100644 index 0000000..a76e123 --- /dev/null +++ b/coalals/utils/files.py @@ -0,0 +1,134 @@ +from os.path import sep, isabs, dirname + +import logging +logger = logging.getLogger(__name__) + + +class UriUtils: + + @staticmethod + def path_from_uri(uri): + if not uri.startswith('file://'): + return uri + + _, path = uri.split('file://', 1) + return path + + @classmethod + def dir_from_uri(cls, uri): + return dirname(cls.path_from_uri(uri)) + + @staticmethod + def file_to_uri(filename): + return 'file://{0}'.format(filename) + + +class FileProxy: + """ + coala requires the files to be flushed to perform + analysis on. This provides an alternative by proving + an always updated proxy of the said file by watching + for events from the client such as didChange. + """ + + @classmethod + def from_name(cls, file, workspace): + with open(file, 'r') as reader: + return cls(file, workspace, reader.read()) + + def __init__(self, filename, workspace=None, contents=''): + logger.debug('File proxy for %s created', filename) + + if not isabs(filename) or filename.endswith(sep): + raise Exception('filename needs to be absolute') + + self._version = -1 + self._filename = filename + self._contents = contents + self._workspace = workspace + self._changes_history = [] + + def __str__(self): + return ''.format( + self._filename, self._version) + + def update(self, diffs): + logger.debug('Updated file proxy %s', self._filename) + + if not isinstance(diffs, list): + diffs = [diffs] + + # TODO Handle the diff applying + + self._changes_history.extend(diffs) + + def replace(self, contents, version): + if version > self._version: + self._contents = contents + self._version = version + return True + + return False + + def contents(self): + return self._contents + + def close(self): + self._contents = '' + + @property + def filename(self): + return self._filename + + @property + def workspace(self): + return self._workspace + + @property + def version(self): + return self._version + + +class FileProxyMap: + + def __init__(self, file_proxies=[]): + self._map = {proxy.filename: proxy for proxy in file_proxies} + + def add(self, proxy, replace=False): + if not isinstance(proxy, FileProxy): + return False + + if self._map.get(proxy.filename) is not None: + if replace: + self._map[proxy.filename] = proxy + return True + return False + + self._map[proxy.filename] = proxy + return True + + def remove(self, filename): + if self.get(filename): + del self._map[filename] + + def get(self, filename): + return self._map.get(filename) + + def resolve(self, filename, workspace=None, hard_sync=True): + proxy = self.get(filename) + if proxy is not None: + return proxy + + try: + proxy = FileProxy.from_name(filename, workspace) + except FileNotFoundError: + if hard_sync: + return False + + try: + proxy = FileProxy(filename, workspace) + except Exception: + return False + + self.add(proxy) + return proxy diff --git a/coalals/utils/log.py b/coalals/utils/log.py new file mode 100644 index 0000000..6068cfd --- /dev/null +++ b/coalals/utils/log.py @@ -0,0 +1,14 @@ +import logging +from sys import stderr + + +def configure_logger(): # pragma: no cover + logging.basicConfig(stream=stderr, level=logging.INFO) + + +def reset_logger(logger=None): # pragma: no cover + logger = logging.getLogger() if logger is None else logger + for handler in logger.handlers[:]: + handler.removeHandler() + + configure_logger() diff --git a/coalals/utils/wrappers.py b/coalals/utils/wrappers.py new file mode 100644 index 0000000..17d88e4 --- /dev/null +++ b/coalals/utils/wrappers.py @@ -0,0 +1,16 @@ +from socketserver import StreamRequestHandler + + +def func_wrapper(func, *args, **kargs): + return func(*args, **kargs) + + +class StreamHandlerWrapper(StreamRequestHandler): + delegate = None + + def setup(self): + super(StreamHandlerWrapper, self).setup() + self.delegate = self.DELEGATE_CLASS(self.rfile, self.wfile) + + def handle(self): + self.delegate.start() diff --git a/docs/images/demo.gif b/docs/images/demo.gif deleted file mode 100644 index 0422985..0000000 Binary files a/docs/images/demo.gif and /dev/null differ diff --git a/docs/images/demo.png b/docs/images/demo.png deleted file mode 100644 index 04dcc55..0000000 Binary files a/docs/images/demo.png and /dev/null differ diff --git a/requirements.txt b/requirements.txt index 03205ec..ba98a71 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -coala>=0.10.0.dev20170213201648 -typing>=3.5.3.0 -coala-bears>=0.10.0.dev20170215041744 -python-language-server~=0.18.0 +coala>=0.11.0 +coala-bears>=0.11.1 +python-jsonrpc-server>=0.0.1 +whatthepatch>=0.0.5 diff --git a/tests/resources/diagnostic/output_char_none.json b/tests/resources/diagnostic/output_char_none.json deleted file mode 100644 index 1147433..0000000 --- a/tests/resources/diagnostic/output_char_none.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "results": { - "autopep8": [ - { - "affected_code": [ - { - "end": { - "column": null, - "line": 1 - }, - "start": { - "column": null, - "line": 1 - } - } - ], - "message": "The code does not comply to PEP8.", - "origin": "PEP8Bear", - "severity": 0 - } - ] - } -} diff --git a/tests/resources/diagnostic/output_multiple_problems.json b/tests/resources/diagnostic/output_multiple_problems.json deleted file mode 100644 index 8ab8236..0000000 --- a/tests/resources/diagnostic/output_multiple_problems.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "results": { - "autopep8": [ - { - "affected_code": [ - { - "end": { - "column": null, - "line": 1 - }, - "start": { - "column": null, - "line": 1 - } - } - ], - "message": "The code does not comply to PEP8.", - "origin": "PEP8Bear", - "severity": 1 - }, - { - "affected_code": [ - { - "end": { - "column": 6, - "line": 1 - }, - "start": { - "column": 6, - "line": 1 - } - } - ], - "message": "W291 trailing whitespace", - "origin": "PycodestyleBear (W291)", - "severity": 1 - } - ], - "cli": [], - "commit": [], - "linelength": [], - "python": [ - { - "affected_code": [ - { - "end": { - "column": null, - "line": 1 - }, - "start": { - "column": null, - "line": 1 - } - } - ], - "message": "Line contains following spacing inconsistencies:\n- Trailing whitespaces.", - "origin": "SpaceConsistencyBear", - "severity": 1 - } - ], - "yml": [] - } -} diff --git a/tests/resources/diagnostic/output_normal_offset.json b/tests/resources/diagnostic/output_normal_offset.json deleted file mode 100644 index 74bb43a..0000000 --- a/tests/resources/diagnostic/output_normal_offset.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "results": { - "autopep8": [ - { - "affected_code": [ - { - "end": { - "column": 2, - "line": 1 - }, - "start": { - "column": 1, - "line": 1 - } - } - ], - "message": "The code does not comply to PEP8.", - "origin": "PEP8Bear", - "severity": 0 - } - ] - } -} diff --git a/tests/resources/diagnostic/output_severity_info.json b/tests/resources/diagnostic/output_severity_info.json deleted file mode 100644 index 1d7421e..0000000 --- a/tests/resources/diagnostic/output_severity_info.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "results": { - "autopep8": [ - { - "affected_code": [ - { - "end": { - "column": 1, - "line": 1 - }, - "start": { - "column": 1, - "line": 1 - } - } - ], - "message": "The code does not comply to PEP8.", - "origin": "PEP8Bear", - "severity": 0 - } - ] - } -} diff --git a/tests/resources/diagnostic/output_severity_major.json b/tests/resources/diagnostic/output_severity_major.json deleted file mode 100644 index 3a3c9fa..0000000 --- a/tests/resources/diagnostic/output_severity_major.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "results": { - "autopep8": [ - { - "affected_code": [ - { - "end": { - "column": 1, - "line": 1 - }, - "start": { - "column": 1, - "line": 1 - } - } - ], - "message": "The code does not comply to PEP8.", - "origin": "PEP8Bear", - "severity": 2 - } - ] - } -} diff --git a/tests/resources/diagnostic/output_severity_normal.json b/tests/resources/diagnostic/output_severity_normal.json deleted file mode 100644 index 4155134..0000000 --- a/tests/resources/diagnostic/output_severity_normal.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "results": { - "autopep8": [ - { - "affected_code": [ - { - "end": { - "column": 1, - "line": 1 - }, - "start": { - "column": 1, - "line": 1 - } - } - ], - "message": "The code does not comply to PEP8.", - "origin": "PEP8Bear", - "severity": 1 - } - ] - } -} diff --git a/tests/resources/failure.py b/tests/resources/failure.py new file mode 100644 index 0000000..7a46da7 --- /dev/null +++ b/tests/resources/failure.py @@ -0,0 +1,2 @@ +def failure(): + print('"'") diff --git a/tests/resources/failure2.py b/tests/resources/failure2.py new file mode 100644 index 0000000..4a990b1 --- /dev/null +++ b/tests/resources/failure2.py @@ -0,0 +1,2 @@ +def sample_failure(x: + pass diff --git a/tests/resources/qualified.py b/tests/resources/qualified.py deleted file mode 100644 index f5efd16..0000000 --- a/tests/resources/qualified.py +++ /dev/null @@ -1 +0,0 @@ -print('Hello, World.') diff --git a/tests/resources/unqualified.py b/tests/resources/unqualified.py deleted file mode 100644 index 2ce46a0..0000000 --- a/tests/resources/unqualified.py +++ /dev/null @@ -1,2 +0,0 @@ -def test(): - a = 1 \ No newline at end of file diff --git a/tests/server.features/coalashim.feature b/tests/server.features/coalashim.feature deleted file mode 100644 index 1cb3c9e..0000000 --- a/tests/server.features/coalashim.feature +++ /dev/null @@ -1,14 +0,0 @@ -Feature: coalashim module - coalashim is a module of language-server, it interacts with coala core. - - Scenario: Test run_coala_with_specific_file - Given the current directory and path of qualified.py - When I pass the qualified.py to run_coala_with_specific_file - Then it should return output in json format - And with no error in the output - - Scenario: Test run_coala_with_specific_file - Given the current directory and path of unqualified.py - When I pass the unqualified.py to run_coala_with_specific_file - Then it should return output in json format - And with autopep8 errors in the output diff --git a/tests/server.features/diagnostic.feature b/tests/server.features/diagnostic.feature deleted file mode 100644 index 8677e5c..0000000 --- a/tests/server.features/diagnostic.feature +++ /dev/null @@ -1,7 +0,0 @@ -Feature: diagnostic module - diagnostic is a module of language-server. - - Scenario: Test output_to_diagnostics - Given the output with errors by coala - When I pass the parameters to output_to_diagnostics - Then it should return output in vscode format diff --git a/tests/server.features/jsonrpc.feature b/tests/server.features/jsonrpc.feature deleted file mode 100644 index 24ae1ad..0000000 --- a/tests/server.features/jsonrpc.feature +++ /dev/null @@ -1,23 +0,0 @@ -Feature: jsonrpc module - jsonrpc is a module of language-server. - - Scenario: Test JsonRpcStreamWriter and JsonRpcStreamReader - Given the message - When I write it to JsonRpcStreamWriter - Then it should read from JsonRpcStreamReader - - Scenario: Test notification and disptacher - Given a notification type rpc request - When I send rpc request using JsonRpcStreamWriter - Then it should invoke the notification consumer with args - - Scenario: Test rpc request and response - Given a request type rpc request - When I send rpc request using JsonRpcStreamWriter - Then it should invoke consumer and return response - - # TODO: block until we have generantee the unique request. -# Scenario: Test send_request -# Given the JSONRPC2Connection instance -# When I write a request to the JSONRPC2Connection with id -# Then it should return the request from JSONRPC2Connection with id diff --git a/tests/server.features/langserver.feature b/tests/server.features/langserver.feature deleted file mode 100644 index 23e2c6e..0000000 --- a/tests/server.features/langserver.feature +++ /dev/null @@ -1,52 +0,0 @@ -Feature: langserver module - langserver is the main program of language-server. - - Scenario: Test serve_initialize with rootPath - Given the LangServer instance - When I send a initialize request with rootPath to the server - Then it should return the response with textDocumentSync - - Scenario: Test serve_initialize with rootUri - Given the LangServer instance - When I send a initialize request with rootUri to the server - Then it should return the response with textDocumentSync - - Scenario: Test send_diagnostics - Given the LangServer instance - When I invoke send_diagnostics message - Then it should send a publishDiagnostics request - - Scenario: Test negative m_text_document__did_save - Given the LangServer instance - When I send a did_save request about a non-existed file to the server - Then it should send a publishDiagnostics request - - Scenario: Test positive m_text_document__did_save - Given the LangServer instance - When I send a did_save request about a existing file to the server - Then it should send a publishDiagnostics request - - Scenario: Test when coafile is missing - Given the LangServer instance - When I send a did_save request on a file with no coafile to server - Then it should send a publishDiagnostics request - - Scenario: Test didChange - Given the LangServer instance - When I send a did_change request about a file to the server - Then it should ignore the request - - Scenario: Test langserver shutdown - Given the LangServer instance - When I send a shutdown request to the server - Then it should shutdown - - Scenario: Test language server in stdio mode - Given I send a initialize request via stdio stream - When the server is started in stdio mode - Then it should return the response with textDocumentSync via stdio - - Scenario: Test language server in tcp mode - Given the server started in TCP mode - When I send a initialize request via TCP stream - Then it should return the response with textDocumentSync via TCP diff --git a/tests/server.features/log.feature b/tests/server.features/log.feature deleted file mode 100644 index 1d7021a..0000000 --- a/tests/server.features/log.feature +++ /dev/null @@ -1,7 +0,0 @@ -Feature: log module - log is a module of language-server. - - Scenario: Test log - Given There is a string - When I pass the string to log - Then it should return normally diff --git a/tests/server.features/steps/coalashim_steps.py b/tests/server.features/steps/coalashim_steps.py deleted file mode 100644 index 0062513..0000000 --- a/tests/server.features/steps/coalashim_steps.py +++ /dev/null @@ -1,61 +0,0 @@ -# -*- coding: UTF-8 -*- - -# @mark.steps -# ---------------------------------------------------------------------------- -# STEPS: -# ---------------------------------------------------------------------------- -import os -import json -from behave import given, when, then -from coala_langserver.coalashim import run_coala_with_specific_file - - -@given('the current directory and path of qualified.py') -def step_impl(context): - context.dir = os.path.abspath( - os.path.join( - os.path.dirname(os.path.abspath(__file__)), - os.pardir, - os.pardir, - os.pardir - ) - ) - context.path = os.path.join(context.dir, 'tests', 'resources', 'qualified.py') - - -@when('I pass the qualified.py to run_coala_with_specific_file') -def step_impl(context): - context.output = run_coala_with_specific_file(context.dir, context.path) - - -@then('it should return output in json format') -def step_impl(context): - assert context.failed is False - - -@then('with no error in the output') -def step_impl(context): - assert context.output is None - - -@given('the current directory and path of unqualified.py') -def step_impl(context): - context.dir = os.path.abspath( - os.path.join( - os.path.dirname(os.path.abspath(__file__)), - os.pardir, - os.pardir, - os.pardir - ) - ) - context.path = os.path.join(context.dir, 'tests', 'resources', 'unqualified.py') - - -@when('I pass the unqualified.py to run_coala_with_specific_file') -def step_impl(context): - context.output = run_coala_with_specific_file(context.dir, context.path) - - -@then('with autopep8 errors in the output') -def step_impl(context): - assert json.loads(context.output)['results']['autopep8'] is not None diff --git a/tests/server.features/steps/diagnostic_steps.py b/tests/server.features/steps/diagnostic_steps.py deleted file mode 100644 index 4baf371..0000000 --- a/tests/server.features/steps/diagnostic_steps.py +++ /dev/null @@ -1,35 +0,0 @@ -# -*- coding: UTF-8 -*- - -# @mark.steps -# ---------------------------------------------------------------------------- -# STEPS: -# ---------------------------------------------------------------------------- -import os -from behave import given, when, then -from coala_langserver.diagnostic import output_to_diagnostics -from coala_langserver.coalashim import run_coala_with_specific_file - - -@given('the output with errors by coala') -def step_impl(context): - context.dir = os.path.abspath( - os.path.join( - os.path.dirname(os.path.abspath(__file__)), - os.pardir, - os.pardir, - 'resources' - ) - ) - context.path = os.path.join(context.dir, 'unqualified.py') - - context.output = run_coala_with_specific_file(context.dir, context.path) - - -@when('I pass the parameters to output_to_diagnostics') -def step_impl(context): - context.message = output_to_diagnostics(context.output) - - -@then('it should return output in vscode format') -def step_impl(context): - assert len(context.message) is not 0 diff --git a/tests/server.features/steps/jsonrpc_steps.py b/tests/server.features/steps/jsonrpc_steps.py deleted file mode 100644 index 3164aff..0000000 --- a/tests/server.features/steps/jsonrpc_steps.py +++ /dev/null @@ -1,128 +0,0 @@ -# -*- coding: UTF-8 -*- - -# @mark.steps -# ---------------------------------------------------------------------------- -# STEPS: -# ---------------------------------------------------------------------------- -import tempfile -import json -from behave import given, when, then -from pyls.jsonrpc import streams -from pyls.jsonrpc import endpoint -from pyls.jsonrpc import dispatchers - - -def issimilar(dicta, dictb): - """ - Return bool indicating if dicta is deeply similar to dictb. - """ - # slow but safe for deeper evaluation - return json.dumps(dicta) == json.dumps(dictb) - - -@given('the message') -def step_impl(context): - context.message = { - 'simple': 'test', - } - - -@when('I write it to JsonRpcStreamWriter') -def step_impl(context): - context.f = tempfile.TemporaryFile(mode='w+b') - context.writer = streams.JsonRpcStreamWriter(context.f) - context.writer.write(context.message) - - -@then('it should read from JsonRpcStreamReader') -def step_impl(context): - context.f.seek(0) - context._passed = False - - def consumer(message): - assert issimilar(context.message, message) - context._passed = True - context.writer.close() - - reader = streams.JsonRpcStreamReader(context.f) - reader.listen(consumer) - reader.close() - - if not context._passed: - assert False - - -@given('a notification type rpc request') -def step_impl(context): - context.request = { - 'jsonrpc': '2.0', - 'method': 'math/add', - 'params': { - 'a': 1, - 'b': 2, - }, - } - - -@when('I send rpc request using JsonRpcStreamWriter') -def step_impl(context): - context.f = tempfile.TemporaryFile() - context.writer = streams.JsonRpcStreamWriter(context.f) - context.writer.write(context.request) - - -@then('it should invoke the notification consumer with args') -def step_impl(context): - context.f.seek(0) - context._passed = False - - class Example(dispatchers.MethodDispatcher): - - def m_math__add(self, a, b): - context.writer.close() - context._passed = True - - epoint = endpoint.Endpoint(Example(), None) - reader = streams.JsonRpcStreamReader(context.f) - reader.listen(epoint.consume) - reader.close() - - if not context._passed: - assert False - - -@given('a request type rpc request') -def step_impl(context): - context.request = { - 'jsonrpc': '2.0', - 'id': 2148, - 'method': 'math/add', - 'params': { - 'a': 1, - 'b': 2, - }, - } - - -@then('it should invoke consumer and return response') -def step_impl(context): - context.f.seek(0) - context._passed = False - - class Example(dispatchers.MethodDispatcher): - - def m_math__add(self, a, b): - return a + b - - def consumer(message): - assert message['result'] == sum(context.request['params'].values()) - context.writer.close() - context._passed = True - - epoint = endpoint.Endpoint(Example(), consumer) - reader = streams.JsonRpcStreamReader(context.f) - reader.listen(epoint.consume) - reader.close() - - if not context._passed: - assert False diff --git a/tests/server.features/steps/langserver_steps.py b/tests/server.features/steps/langserver_steps.py deleted file mode 100644 index ee4706e..0000000 --- a/tests/server.features/steps/langserver_steps.py +++ /dev/null @@ -1,358 +0,0 @@ -# -*- coding: UTF-8 -*- - -# @mark.steps -# ---------------------------------------------------------------------------- -# STEPS: -# ---------------------------------------------------------------------------- -import io -import os -import sys -import time -import socket -import tempfile -from threading import Thread - -from behave import given, when, then -from unittest import mock - -from pyls.jsonrpc import streams -from coala_langserver.langserver import LangServer, start_io_lang_server, main - - -@given('the LangServer instance') -def step_impl(context): - context.f = tempfile.TemporaryFile() - context.langServer = LangServer(context.f, context.f) - - -@when('I send a initialize request with rootPath to the server') -def step_impl(context): - request = { - 'method': 'initialize', - 'params': { - 'rootPath': '/Users/mock-user/mock-dir', - 'capabilities': {}, - }, - 'id': 1, - 'jsonrpc': '2.0' - } - context.langServer._endpoint.consume(request) - - -@when('I send a initialize request with rootUri to the server') -def step_impl(context): - request = { - 'method': 'initialize', - 'params': { - 'rootUri': '/Users/mock-user/mock-dir', - 'capabilities': {}, - }, - 'id': 1, - 'jsonrpc': '2.0', - } - context.langServer._endpoint.consume(request) - - -@then('it should return the response with textDocumentSync') -def step_impl(context): - context.f.seek(0) - context._passed = False - - def consumer(response): - assert response is not None - assert response['result']['capabilities']['textDocumentSync'] == 1 - context.f.close() - context._passed = True - - reader = streams.JsonRpcStreamReader(context.f) - reader.listen(consumer) - reader.close() - - if not context._passed: - assert False - - -@when('I invoke send_diagnostics message') -def step_impl(context): - context.langServer.send_diagnostics('/sample', []) - context._diagCount = 0 - - -@when('I send a did_save request about a non-existed file to the server') -def step_impl(context): - request = { - 'method': 'textDocument/didSave', - 'params': { - 'textDocument': { - 'uri': 'file:///Users/mock-user/non-exist.py' - } - }, - 'jsonrpc': '2.0', - } - context.langServer._endpoint.consume(request) - context._diagCount = 0 - - -@when('I send a did_save request about a existing file to the server') -def step_impl(context): - thisfile = os.path.realpath(__file__) - thisdir = os.path.dirname(thisfile) - parturi = os.path.join(thisdir, '../../resources', 'unqualified.py') - absparturi = os.path.abspath(parturi) - - request = { - 'method': 'textDocument/didSave', - 'params': { - 'textDocument': { - 'uri': 'file://{}'.format(absparturi), - }, - }, - 'jsonrpc': '2.0', - } - context.langServer._endpoint.consume(request) - context._diagCount = 4 - - -@when('I send a did_save request on a file with no coafile to server') -def step_impl(context): - somefile = tempfile.NamedTemporaryFile(delete=False) - somefilename = somefile.name - somefile.close() - - request = { - 'method': 'textDocument/didSave', - 'params': { - 'textDocument': { - 'uri': 'file://{}'.format(somefilename), - }, - }, - 'jsonrpc': '2.0' - } - context.langServer._endpoint.consume(request) - context._diagCount = 0 - - -@then('it should send a publishDiagnostics request') -def step_impl(context): - context.f.seek(0) - context._passed = False - - def consumer(response): - assert response is not None - assert response['method'] == 'textDocument/publishDiagnostics' - assert len(response['params']['diagnostics']) is context._diagCount - - context.f.close() - context._passed = True - - reader = streams.JsonRpcStreamReader(context.f) - reader.listen(consumer) - reader.close() - - if not context._passed: - assert False - - -@when('I send a did_change request about a file to the server') -def step_impl(context): - thisfile = os.path.realpath(__file__) - thisdir = os.path.dirname(thisfile) - parturi = os.path.join(thisdir, '../../resources', 'unqualified.py') - - request = { - 'method': 'textDocument/didChange', - 'params': { - 'textDocument': { - 'uri': 'file://{}'.format(parturi), - }, - 'contentChanges': [ - { - 'text': 'def test():\n a = 1\n', - }, - ], - }, - 'jsonrpc': '2.0', - } - context.langServer._endpoint.consume(request) - - -@then('it should ignore the request') -def step_impl(context): - length = context.f.seek(0, os.SEEK_END) - assert length == 0 - context.f.close() - - -@when('I send a shutdown request to the server') -def step_impl(context): - request = { - 'method': 'shutdown', - 'params': None, - 'id': 1, - 'jsonrpc': '2.0', - } - context.langServer._endpoint.consume(request) - - -@then('it should shutdown') -def step_impl(context): - context.f.seek(0) - context._passed = False - - def consumer(response): - assert response is not None - assert response['result'] is None - - context.f.close() - context._passed = True - - reader = streams.JsonRpcStreamReader(context.f) - reader.listen(consumer) - reader.close() - - assert context._passed - assert context.langServer._shutdown - - -def gen_alt_log(context, mode='tcp'): - if mode == 'tcp': - check = 'Serving LangServer on (0.0.0.0, 20801)\n' - elif mode == 'stdio': - check = 'Starting LangServer IO language server\n' - else: - assert False - - def alt_log(*args, **kargs): - result = io.StringIO() - print(*args, file=result, **kargs) - - value = result.getvalue() - if value == check: - context._server_alive = True - - return alt_log - - -@given('the server started in TCP mode') -def step_impl(context): - context._server_alive = False - host, port = ('0.0.0.0', 20801) - - with mock.patch('coala_langserver.langserver.log') as mock_log: - mock_log.side_effect = gen_alt_log(context) - - sys.argv = ['', '--mode', 'tcp', '--addr', str(port)] - context.thread = Thread(target=main) - context.thread.daemon = True - context.thread.start() - - for _ in range(20): - if context._server_alive: - break - else: - time.sleep(1) - else: - assert False - - context.sock = socket.create_connection( - address=(host, port), timeout=10) - context.f = context.sock.makefile('rwb') - - context.reader = streams.JsonRpcStreamReader(context.f) - context.writer = streams.JsonRpcStreamWriter(context.f) - - -@when('I send a initialize request via TCP stream') -def step_impl(context): - request = { - 'method': 'initialize', - 'params': { - 'rootUri': '/Users/mock-user/mock-dir', - 'capabilities': {}, - }, - 'id': 1, - 'jsonrpc': '2.0', - } - context.writer.write(request) - - -@then('it should return the response with textDocumentSync via TCP') -def step_impl(context): - context._passed = False - - def consumer(response): - assert response is not None - assert response['result']['capabilities']['textDocumentSync'] == 1 - context.f.close() - context._passed = True - - context.reader.listen(consumer) - context.reader.close() - context.sock.close() - - if not context._passed: - assert False - - -@given('I send a initialize request via stdio stream') -def step_impl(context): - context.f = tempfile.TemporaryFile() - context.writer = streams.JsonRpcStreamWriter(context.f) - - request = { - 'method': 'initialize', - 'params': { - 'rootUri': '/Users/mock-user/mock-dir', - 'capabilities': {}, - }, - 'id': 1, - 'jsonrpc': '2.0', - } - context.writer.write(request) - context.f.seek(0) - - -@when('the server is started in stdio mode') -def step_impl(context): - context._server_alive = False - context.o = tempfile.TemporaryFile() - - with mock.patch('coala_langserver.langserver.log') as mock_log: - mock_log.side_effect = gen_alt_log(context, 'stdio') - - context.thread = Thread(target=start_io_lang_server, args=( - LangServer, context.f, context.o)) - context.thread.daemon = True - context.thread.start() - - for _ in range(10): - if context._server_alive: - break - else: - time.sleep(1) - else: - assert False - - -@then('it should return the response with textDocumentSync via stdio') -def step_impl(context): - context._passed = False - - def consumer(response): - assert response is not None - assert response['result']['capabilities']['textDocumentSync'] == 1 - context.f.close() - context._passed = True - - last = -9999 - while context.o.tell() != last: - last = context.o.tell() - time.sleep(1) - - context.o.seek(0) - context.reader = streams.JsonRpcStreamReader(context.o) - context.reader.listen(consumer) - context.reader.close() - - if not context._passed: - assert False diff --git a/tests/server.features/steps/log_steps.py b/tests/server.features/steps/log_steps.py deleted file mode 100644 index af9287e..0000000 --- a/tests/server.features/steps/log_steps.py +++ /dev/null @@ -1,23 +0,0 @@ -# -*- coding: UTF-8 -*- - -# @mark.steps -# ---------------------------------------------------------------------------- -# STEPS: -# ---------------------------------------------------------------------------- -from behave import given, when, then -from coala_langserver.log import log - - -@given('There is a string') -def step_impl(context): - context.str = 'file://Users' - - -@when('I pass the string to log') -def step_impl(context): - log(context.str) - - -@then('it should return normally') -def step_impl(context): - assert context.failed is False diff --git a/tests/server.features/steps/uri_steps.py b/tests/server.features/steps/uri_steps.py deleted file mode 100644 index fdb2d74..0000000 --- a/tests/server.features/steps/uri_steps.py +++ /dev/null @@ -1,45 +0,0 @@ -# -*- coding: UTF-8 -*- - -# @mark.steps -# ---------------------------------------------------------------------------- -# STEPS: -# ---------------------------------------------------------------------------- -from behave import given, when, then -from coala_langserver.uri import path_from_uri, dir_from_uri - - -@given('There is a string with "file://"') -def step_impl(context): - context.str = 'file://Users' - - -@when('I pass the string with the prefix to path_from_uri') -def step_impl(context): - context.path = path_from_uri(context.str) - - -@given('There is a string without "file://"') -def step_impl(context): - context.str = '/Users' - - -@when('I pass the string without the prefix to path_from_uri') -def step_impl(context): - context.path = path_from_uri(context.str) - - -@then('it should return a string without "file://"') -def step_impl(context): - assert context.failed is False - assert 'file://' not in context.path - - -@when('I pass the string to dir_from_uri') -def step_impl(context): - context.path = dir_from_uri(context.str) - - -@then('it should return the directory of the path') -def step_impl(context): - assert context.failed is False - assert context.path is '/' diff --git a/tests/server.features/uri.feature b/tests/server.features/uri.feature deleted file mode 100644 index fc2d2c2..0000000 --- a/tests/server.features/uri.feature +++ /dev/null @@ -1,17 +0,0 @@ -Feature: uri module - uri is a module of language-server. - - Scenario: Test path_from_uri - Given There is a string with "file://" - When I pass the string with the prefix to path_from_uri - Then it should return a string without "file://" - - Scenario: Test path_from_uri - Given There is a string without "file://" - When I pass the string without the prefix to path_from_uri - Then it should return a string without "file://" - - Scenario: Test dir_from_uri - Given There is a string without "file://" - When I pass the string to dir_from_uri - Then it should return the directory of the path diff --git a/tests/test_coalashim.py b/tests/test_coalashim.py deleted file mode 100644 index af0ac01..0000000 --- a/tests/test_coalashim.py +++ /dev/null @@ -1,75 +0,0 @@ -import unittest -from unittest import mock - -from coala_langserver.coalashim import run_coala_with_specific_file - - -def generate_side_effect(message, ret): - def side_effect(): - print(message, end=''), - return ret - return side_effect - - -@mock.patch('coala_langserver.coalashim.log') -@mock.patch('coala_langserver.coalashim.coala') -class ShimTestCase(unittest.TestCase): - - def test_call(self, mock_coala, mock_log): - mock_coala.return_value = 1 - run_coala_with_specific_file(None, None) - - # coala is called without arguments - mock_coala.main.assert_called_with() - - def test_issue_with_result(self, mock_coala, mock_log): - message = 'issue found' - mock_coala.main.side_effect = generate_side_effect(message, 1) - output = run_coala_with_specific_file(None, None) - - # log is message information - mock_log.assert_called_with('Output =', message) - # return value is issue message - self.assertEqual(message, output) - - def test_issue_no_result(self, mock_coala, mock_log): - message = '' - mock_coala.main.side_effect = generate_side_effect(message, 1) - output = run_coala_with_specific_file(None, None) - - # log is `no results` reminder - mock_log.assert_called_with('No results for the file') - # return value is empty string - self.assertEqual(message, output) - - def test_no_issue(self, mock_coala, mock_log): - message = 'no issue' - mock_coala.main.side_effect = generate_side_effect(message, 0) - output = run_coala_with_specific_file(None, None) - - # log is `no issue` reminder - mock_log.assert_called_with('No issues found') - # return value is None - self.assertEqual(None, output) - - def test_coala_error(self, mock_coala, mock_log): - message = 'fatal error' - mock_coala.main.side_effect = generate_side_effect(message, -1) - output = run_coala_with_specific_file(None, None) - - # log is `exit` reminder - mock_log.assert_called_with('Exited with:', -1) - # return value is None - self.assertEqual(None, output) - - @mock.patch('coala_langserver.coalashim.os') - def test_working_dir_normal(self, mock_os, mock_coala, mock_log): - working_dir = '/' - run_coala_with_specific_file(working_dir, None) - mock_os.chdir.assert_called_with(working_dir) - - @mock.patch('coala_langserver.coalashim.os') - def test_working_dir_none(self, mock_os, mock_coala, mock_log): - working_dir = None - run_coala_with_specific_file(working_dir, None) - mock_os.chdir.assert_called_with('.') diff --git a/tests/test_diagnostic.py b/tests/test_diagnostic.py deleted file mode 100644 index 62d1949..0000000 --- a/tests/test_diagnostic.py +++ /dev/null @@ -1,75 +0,0 @@ -import os -import unittest - -from coala_langserver.diagnostic import output_to_diagnostics - - -def get_output(filename): - file_path = os.path.join(os.path.dirname(__file__), - 'resources/diagnostic', - filename) - with open(file_path, 'r') as file: - output = file.read() - return output - - -class DiagnosticTestCase(unittest.TestCase): - - def test_none_output(self): - result = output_to_diagnostics(None) - self.assertEqual(result, None) - - def test_severity_info(self): - output = get_output('output_severity_info.json') - result = output_to_diagnostics(output) - - # INFO: 0 (coala) -> Information: 3 (LSP) - self.assertEqual(result[0]['severity'], 3) - - def test_severity_normal(self): - output = get_output('output_severity_normal.json') - result = output_to_diagnostics(output) - - # NORMAL: 1 (coala) -> Warning: 2 (LSP) - self.assertEqual(result[0]['severity'], 2) - - def test_severity_major(self): - output = get_output('output_severity_major.json') - result = output_to_diagnostics(output) - - # MAJOR: 2 (coala) -> Error: 1 (LSP) - self.assertEqual(result[0]['severity'], 1) - - def test_char_none(self): - output = get_output('output_char_none.json') - result = output_to_diagnostics(output) - - # None column should be regarded as the whole line - start_line = result[0]['range']['start']['line'] - start_char = result[0]['range']['start']['character'] - end_line = result[0]['range']['end']['line'] - end_char = result[0]['range']['end']['character'] - self.assertEqual(start_char, 0) - self.assertEqual(end_char, 0) - self.assertEqual(start_line + 1, end_line) - - def test_normal_offset(self): - output = get_output('output_normal_offset.json') - result = output_to_diagnostics(output) - - # normal offset, one-based -> zero-based - start_line = result[0]['range']['start']['line'] - start_char = result[0]['range']['start']['character'] - end_line = result[0]['range']['end']['line'] - end_char = result[0]['range']['end']['character'] - self.assertEqual(start_char, 0) - self.assertEqual(end_char, 1) - self.assertEqual(start_line, 0) - self.assertEqual(end_line, 0) - - def test_multiple_problems(self): - output = get_output('output_multiple_problems.json') - result = output_to_diagnostics(output) - - # should be able to handle multiple bears & problems - self.assertEqual(len(result), 3)