diff --git a/ensime_shared/client.py b/ensime_shared/client.py index 857851b..0fe7657 100644 --- a/ensime_shared/client.py +++ b/ensime_shared/client.py @@ -193,8 +193,9 @@ def lazy_initialize_ensime(): self.log.debug(str(inspect.stack())) self.log.debug('setup(quiet=%s, bootstrap_server=%s) called by %s()', quiet, bootstrap_server, called_by) - no_classpath = not os.path.exists(self.launcher.classpath_file) - if not bootstrap_server and no_classpath: + + installed = self.launcher.strategy.isinstalled() + if not installed and not bootstrap_server: if not quiet: scala = self.launcher.config.get('scala-version') msg = feedback["prompt_server_install"].format(scala_version=scala) @@ -261,6 +262,7 @@ def reconnect(e): def connect_ensime_server(self): """Start initial connection with the server.""" self.log.debug('connect_ensime_server: in') + server_v2 = isinstance(self, EnsimeClientV2) def disable_completely(e): if e: @@ -272,12 +274,12 @@ def disable_completely(e): self.number_try_connection -= 1 if not self.ensime_server: port = self.ensime.http_port() - uri = "websocket" if self.launcher.server_v2 else "jerky" + uri = "websocket" if server_v2 else "jerky" self.ensime_server = gconfig["ensime_server"].format(port, uri) with catch(Exception, disable_completely): from websocket import create_connection # Use the default timeout (no timeout). - options = {"subprotocols": ["jerky"]} if self.launcher.server_v2 else {} + options = {"subprotocols": ["jerky"]} if server_v2 else {} self.log.debug("About to connect to %s with options %s", self.ensime_server, options) self.ws = create_connection(self.ensime_server, **options) diff --git a/ensime_shared/config.py b/ensime_shared/config.py index 43e18fb..ca7a41e 100644 --- a/ensime_shared/config.py +++ b/ensime_shared/config.py @@ -7,7 +7,7 @@ from ensime_shared.util import Util -BOOTSTRAPS_ROOT = os.path.join(os.environ['HOME'], '.config/ensime-vim/') +BOOTSTRAPS_ROOT = os.path.join(os.environ['HOME'], '.config', 'ensime-vim') """Default directory where ENSIME server bootstrap projects will be created.""" LOG_FORMAT = '%(levelname)-8s <%(asctime)s> (%(filename)s:%(lineno)d) - %(message)s' diff --git a/ensime_shared/ensime.py b/ensime_shared/ensime.py index 40ded98..3410f19 100644 --- a/ensime_shared/ensime.py +++ b/ensime_shared/ensime.py @@ -50,8 +50,9 @@ def __init__(self, vim): self._vim = vim self.clients = {} + @property def using_server_v2(self): - """Whether user has configured the plugin to use ENSIME v2 protocol.""" + """bool: Whether user has configured the plugin to use ENSIME v2 protocol.""" return bool(self.get_setting('server_v2', 0)) def get_setting(self, key, default): @@ -109,10 +110,11 @@ def create_client(self, config_path): This will launch the ENSIME server for the project as a side effect. """ - server_v2 = self.using_server_v2() + config = ProjectConfig(config_path) editor = Editor(self._vim) - launcher = EnsimeLauncher(self._vim, config_path, server_v2) - if server_v2: + launcher = EnsimeLauncher(self._vim, config) + + if self.using_server_v2: return EnsimeClientV2(editor, self._vim, launcher) else: return EnsimeClientV1(editor, self._vim, launcher) diff --git a/ensime_shared/errors.py b/ensime_shared/errors.py index 7d5823c..eb74222 100644 --- a/ensime_shared/errors.py +++ b/ensime_shared/errors.py @@ -10,6 +10,10 @@ def __init__(self, errno, msg, filename, *args): super(InvalidJavaPathError, self).__init__(errno, msg, filename, *args) +class LaunchError(RuntimeError): + """Raised when ensime-vim cannot launch the ENSIME server.""" + + class Error(object): """Represents an error in source code reported by ENSIME.""" diff --git a/ensime_shared/launcher.py b/ensime_shared/launcher.py index 0193e2e..009c9a2 100644 --- a/ensime_shared/launcher.py +++ b/ensime_shared/launcher.py @@ -1,7 +1,6 @@ # coding: utf-8 import errno -import fnmatch import os import shutil import signal @@ -9,10 +8,12 @@ import subprocess import time +from abc import ABCMeta, abstractmethod +from fnmatch import fnmatch from string import Template -from ensime_shared.config import BOOTSTRAPS_ROOT, ProjectConfig -from ensime_shared.errors import InvalidJavaPathError +from ensime_shared.config import BOOTSTRAPS_ROOT +from ensime_shared.errors import InvalidJavaPathError, LaunchError from ensime_shared.util import catch, Util @@ -36,6 +37,7 @@ def aborted(self): return not (self.__stopped_manually or self.is_running()) def is_running(self): + # What? If there's no process, it's running? This is mad confusing. return self.process is None or self.process.poll() is None def is_ready(self): @@ -55,50 +57,103 @@ def http_port(self): class EnsimeLauncher(object): - ENSIME_V1 = '1.0.0' - ENSIME_V2 = '2.0.0-SNAPSHOT' - SBT_VERSION = '0.13.12' + """Launches ENSIME processes, installing the server if needed.""" - def __init__(self, vim, config_path, server_v2, base_dir=BOOTSTRAPS_ROOT): - self.vim = vim - self.server_v2 = server_v2 - self.ensime_version = self.ENSIME_V2 if server_v2 else self.ENSIME_V1 - self._config_path = os.path.abspath(config_path) - self.config = ProjectConfig(self._config_path) - self.scala_minor = self.config['scala-version'][:4] - self.base_dir = os.path.abspath(base_dir) - self.classpath_file = os.path.join(self.base_dir, - self.scala_minor, - self.ensime_version, - 'classpath') - self._migrate_legacy_bootstrap_location() + def __init__(self, vim, config, base_dir=BOOTSTRAPS_ROOT): + self.config = config + + # If an ENSIME assembly jar is in place, it takes launch precedence + assembly = AssemblyJar(config, base_dir) + + if assembly.isinstalled(): + self.strategy = assembly + elif self.config.get('ensime-server-jars'): + self.strategy = DotEnsimeLauncher(config) + else: + self.strategy = SbtBootstrap(vim, config, base_dir) + + self._remove_legacy_bootstrap() + # Design musing: we could return a Boolean success value then encapsulate + # and expose more lifecycle control through EnsimeLauncher, instead of + # pushing up an EnsimeProcess and leaving callers with the responsibilities + # of dealing with that. EnsimeClient needs a bunch of (worthwhile) refactoring + # before this could happen, though. def launch(self): + # This is legacy -- what is it really accomplishing? cache_dir = self.config['cache-dir'], process = EnsimeProcess(cache_dir, None, None, lambda: None) if process.is_ready(): return process - classpath = self.load_classpath() - return self.start_process(classpath) if classpath else None - - def load_classpath(self): - if not os.path.exists(self.classpath_file): - if not self.generate_classpath(): + if not self.strategy.isinstalled(): + if not self.strategy.install(): # TODO: This should be an exception return None - classpath = "{}:{}/lib/tools.jar".format( - Util.read_file(self.classpath_file), self.config['java-home']) + return self.strategy.launch() + + @staticmethod + def _remove_legacy_bootstrap(): + """Remove bootstrap projects from old path, they'd be really stale by now.""" + home = os.environ['HOME'] + old_base_dir = os.path.join(home, '.config', 'classpath_project_ensime') + if os.path.isdir(old_base_dir): + shutil.rmtree(old_base_dir, ignore_errors=True) + + +class LaunchStrategy: + """A strategy for how to install and launch the ENSIME server. - # Allow override with a local development server jar, see: - # http://ensime.github.io/contributing/#manual-qa-testing - for x in os.listdir(self.base_dir): - if fnmatch.fnmatch(x, "ensime_" + self.scala_minor + "*-assembly.jar"): - classpath = os.path.join(self.base_dir, x) + ":" + classpath + Newer build tool versions like sbt-ensime since 1.12.0 may support + installing the server and publishing the jar locations in ``.ensime`` + so that clients don't need to handle installation. Strategies exist to + support older versions and build tools that haven't caught up to this. - return classpath + Args: + config (ProjectConfig): Configuration for the server instance's project. + """ + __metaclass__ = ABCMeta - def start_process(self, classpath): + def __init__(self, config): + self.config = config + + @abstractmethod + def isinstalled(self): + """Whether ENSIME has been installed satisfactorily for the launcher.""" + raise NotImplementedError + + @abstractmethod + def install(self): + """Installs ENSIME server if needed. + + Returns: + bool: Whether the installation completed successfully. + """ + raise NotImplementedError + + @abstractmethod + def launch(self): + """Launches a server instance for the configured project. + + Returns: + EnsimeProcess: A process handle for the launched server. + + Raises: + LaunchError: If server can't be launched according to the strategy. + """ + raise NotImplementedError + + def _start_process(self, classpath): + """Given a classpath prepared for running ENSIME, spawns a server process + in a way that is otherwise agnostic to how the strategy installs ENSIME. + + Args: + classpath (str): Colon-separated classpath string suitable for passing + as an argument to ``java -cp``. + + Returns: + EnsimeProcess: A process handle for the launched server. + """ cache_dir = self.config['cache-dir'] java_flags = self.config['java-flags'] @@ -115,8 +170,8 @@ def start_process(self, classpath): args = ( [java, "-cp", classpath] + - [a for a in java_flags if a != ""] + - ["-Densime.config={}".format(self._config_path), + [a for a in java_flags if a] + + ["-Densime.config={}".format(self.config.filepath), "org.ensime.server.Server"]) process = subprocess.Popen( args, @@ -129,13 +184,114 @@ def start_process(self, classpath): def on_stop(): log.close() null.close() - with catch(Exception, lambda e: None): + with catch(Exception): os.remove(pid_path) return EnsimeProcess(cache_dir, process, log_path, on_stop) - def generate_classpath(self): + +class AssemblyJar(LaunchStrategy): + """Launches an ENSIME assembly jar if found in ``~/.config/ensime-vim`` (or + base_dir). This is intended for ad hoc local development builds, or behind- + the-firewall corporate installs. See: + + http://ensime.github.io/contributing/#manual-qa-testing + """ + + def __init__(self, config, base_dir): + super(AssemblyJar, self).__init__(config) + self.base_dir = os.path.realpath(base_dir) + self.jar_path = None + self.toolsjar = os.path.join(config['java-home'], 'lib', 'tools.jar') + + def isinstalled(self): + scala_minor = self.config['scala-version'][:4] + for fname in os.listdir(self.base_dir): + if fnmatch(fname, "ensime_" + scala_minor + "*-assembly.jar"): + self.jar_path = os.path.join(self.base_dir, fname) + return True + + return False + + def install(self): + # Nothing to do for this strategy, server is built in the jar + return True + + def launch(self): + if not self.isinstalled(): + raise LaunchError('ENSIME assembly jar not found in {}'.format(self.base_dir)) + + classpath = [self.jar_path, self.toolsjar] + self.config['scala-compiler-jars'] + return self._start_process(':'.join(classpath)) + + +class DotEnsimeLauncher(LaunchStrategy): + """Launches a pre-installed ENSIME via jar paths in ``.ensime``.""" + + def __init__(self, config): + super(DotEnsimeLauncher, self).__init__(config) + server_jars = self.config['ensime-server-jars'] + compiler_jars = self.config['scala-compiler-jars'] + + # Order is important so that monkeys takes precedence + self.classpath = server_jars + compiler_jars + + def isinstalled(self): + return all([os.path.exists(jar) for jar in self.classpath]) + + def install(self): + # Nothing to do, the build tool has done it if we're in this strategy + return True + + def launch(self): + if not self.isinstalled(): + raise LaunchError('Some jars reported by .ensime do not exist: {}' + .format(self.classpath)) + return self._start_process(':'.join(self.classpath)) + + +class SbtBootstrap(LaunchStrategy): + """Install ENSIME via sbt with a bootstrap project. + + This strategy is intended for versions of sbt-ensime prior to 1.12.0 + and other build tools that don't install ENSIME & report its jar paths. + + Support for this installation method will be dropped after users and build + tools have some time to catch up. Consider it deprecated. + """ + ENSIME_V1 = '1.0.0' + SBT_VERSION = '0.13.13' + SBT_COURSIER_COORDS = ('io.get-coursier', 'sbt-coursier', '1.0.0-M15') + + def __init__(self, vim, config, base_dir): + super(SbtBootstrap, self).__init__(config) + self.vim = vim + self.ensime_version = self.ENSIME_V1 + self.scala_minor = self.config['scala-version'][:4] + self.base_dir = os.path.realpath(base_dir) + self.toolsjar = os.path.join(self.config['java-home'], 'lib', 'tools.jar') + self.classpath_file = os.path.join(self.base_dir, + self.scala_minor, + self.ensime_version, + 'classpath') + + def launch(self): + if not self.isinstalled(): + raise LaunchError('Bootstrap classpath file does not exist at {}' + .format(self.classpath_file)) + + classpath = Util.read_file(self.classpath_file) + ':' + self.toolsjar + return self._start_process(classpath) + + # TODO: should maybe check if the build.sbt matches spec (versions, etc.) + def isinstalled(self): + return os.path.exists(self.classpath_file) + + def install(self): + """Installs ENSIME server with a bootstrap sbt project and generates its classpath.""" project_dir = os.path.dirname(self.classpath_file) + sbt_plugin = """addSbtPlugin("{0}" % "{1}" % "{2}")""" + Util.mkdir_p(project_dir) Util.mkdir_p(os.path.join(project_dir, "project")) Util.write_file( @@ -146,14 +302,14 @@ def generate_classpath(self): "sbt.version={}".format(self.SBT_VERSION)) Util.write_file( os.path.join(project_dir, "project", "plugins.sbt"), - """addSbtPlugin("io.get-coursier" % "sbt-coursier" % "1.0.0-M11")""") + sbt_plugin.format(*self.SBT_COURSIER_COORDS)) # Synchronous update of the classpath via sbt # see https://github.com/ensime/ensime-vim/issues/29 cd_cmd = "cd {}".format(project_dir) sbt_cmd = "sbt -Dsbt.log.noformat=true -batch saveClasspath" - inside_nvim = int(self.vim.eval("has('nvim')")) - if inside_nvim: + + if int(self.vim.eval("has('nvim')")): import tempfile import re tmp_dir = tempfile.gettempdir() @@ -234,7 +390,7 @@ def reorder_classpath(self, classpath_file): """Reorder classpath and put monkeys-jar in the first place.""" success = False - with catch((IOError, OSError), lambda e: None): + with catch((IOError, OSError)): with open(classpath_file, "r") as f: classpath = f.readline() @@ -255,11 +411,3 @@ def reorder_classpath(self, classpath_file): success = True return success - - @staticmethod - def _migrate_legacy_bootstrap_location(): - """Moves an old ENSIME installer root to tidier location.""" - home = os.environ['HOME'] - old_base_dir = os.path.join(home, '.config/classpath_project_ensime') - if os.path.isdir(old_base_dir): - shutil.move(old_base_dir, BOOTSTRAPS_ROOT) diff --git a/ensime_shared/typecheck.py b/ensime_shared/typecheck.py index f17997d..a33f0a7 100644 --- a/ensime_shared/typecheck.py +++ b/ensime_shared/typecheck.py @@ -16,7 +16,9 @@ def buffer_typechecks(self, call_id, payload): def buffer_typechecks_and_display(self, call_id, payload): """Adds typecheck events to the buffer, and displays them right away. - This is currently used as a workaround for issue https://github.com/ensime/ensime-server/issues/1616 + + This is a workaround for this issue: + https://github.com/ensime/ensime-server/issues/1616 """ self.buffer_typechecks(call_id, payload) self.editor.display_notes(self.buffered_notes) diff --git a/test/resources/test-bootstrap.conf b/test/resources/test-bootstrap.conf new file mode 100644 index 0000000..1f9d2be --- /dev/null +++ b/test/resources/test-bootstrap.conf @@ -0,0 +1,7 @@ +( + :name "testing" + :scala-version "2.11.8" + + :java-home "/fake/opt/java" + :scala-compiler-jars ("/fake/cache/scala-compiler-2.11.8.jar" "/fake/cache/scala-library-2.11.8.jar") +) diff --git a/test/resources/test-server-jars.conf b/test/resources/test-server-jars.conf new file mode 100644 index 0000000..789a249 --- /dev/null +++ b/test/resources/test-server-jars.conf @@ -0,0 +1,7 @@ +( + :name "testing" + :scala-version "2.11.8" + :java-home "/fake/opt/java" + :scala-compiler-jars ("/fake/cache/scala-compiler-2.11.8.jar" "/fake/cache/scala-library-2.11.8.jar") + :ensime-server-jars ("/fake/cache/monkeys_2.11-1.0.0.jar" "/fake/cache/server_2.11-1.0.0.jar") +) diff --git a/test/test_launcher.py b/test/test_launcher.py new file mode 100644 index 0000000..74fdb0e --- /dev/null +++ b/test/test_launcher.py @@ -0,0 +1,127 @@ +# coding: utf-8 + +import pytest +from mock import patch +from py import path + +from ensime_shared.config import ProjectConfig +from ensime_shared.errors import LaunchError +from ensime_shared.launcher import (AssemblyJar, DotEnsimeLauncher, + EnsimeLauncher, SbtBootstrap) + +CONFROOT = path.local(__file__).dirpath() / 'resources' + + +def test_determines_launch_strategy(tmpdir, vim): + base_dir = tmpdir.strpath + bootstrap_conf = config('test-bootstrap.conf') + + launcher = EnsimeLauncher(vim, config('test-server-jars.conf'), base_dir) + assert isinstance(launcher.strategy, DotEnsimeLauncher) + + launcher = EnsimeLauncher(vim, bootstrap_conf, base_dir) + assert isinstance(launcher.strategy, SbtBootstrap) + + create_stub_assembly_jar(base_dir, bootstrap_conf) + launcher = EnsimeLauncher(vim, bootstrap_conf, base_dir) + assert isinstance(launcher.strategy, AssemblyJar) + + +class TestAssemblyJarStrategy: + @pytest.fixture + def strategy(self, tmpdir): + return AssemblyJar(config('test-bootstrap.conf'), base_dir=tmpdir.strpath) + + @pytest.fixture + def assemblyjar(self, strategy): + create_stub_assembly_jar(strategy.base_dir, strategy.config) + + def test_isinstalled_if_jar_file_present(self, strategy): + assert not strategy.isinstalled() + self.assemblyjar(strategy) + assert strategy.isinstalled() + + def test_launch_constructs_classpath(self, strategy, assemblyjar): + assert strategy.isinstalled() + with patch.object(strategy, '_start_process', autospec=True) as start: + strategy.launch() + + assert start.call_count == 1 + args, _kwargs = start.call_args + classpath = args[0].split(':') + assert classpath == [strategy.jar_path, + strategy.toolsjar, + ] + strategy.config['scala-compiler-jars'] + + def test_launch_raises_when_not_installed(self, strategy): + assert not strategy.isinstalled() + with pytest.raises(LaunchError) as excinfo: + strategy.launch() + assert 'assembly jar not found' in str(excinfo.value) + + +class TestDotEnsimeStrategy: + @pytest.fixture + def strategy(self): + return DotEnsimeLauncher(config('test-server-jars.conf')) + + def test_adds_server_jars_to_classpath(self, strategy): + server_jars = strategy.config['ensime-server-jars'] + assert all([jar in strategy.classpath for jar in server_jars]) + + def test_isinstalled_if_jars_present(self, strategy): + assert not strategy.isinstalled() + # Stub the existence of the server+compiler jars + with patch('os.path.exists', return_value=True): + assert strategy.isinstalled() + + def test_launch_constructs_classpath(self, strategy): + with patch.object(strategy, '_start_process', autospec=True) as start: + with patch.object(strategy, 'isinstalled', return_value=True): + strategy.launch() + + assert start.call_count == 1 + args, _kwargs = start.call_args + classpath = args[0].split(':') + assert classpath == strategy.classpath + + def test_launch_raises_when_not_installed(self, strategy): + assert not strategy.isinstalled() + with pytest.raises(LaunchError) as excinfo: + strategy.launch() + assert 'Some jars reported by .ensime do not exist' in str(excinfo.value) + + +class TestSbtBootstrapStrategy: + """ + Minimally tested because unit testing this would be obnoxious and brittle... + """ + + @pytest.fixture + def strategy(self, tmpdir, vim): + conf = config('test-bootstrap.conf') + return SbtBootstrap(vim, conf, base_dir=tmpdir.strpath) + + def test_isinstalled_if_classpath_file_present(self, strategy): + assert not strategy.isinstalled() + + def test_launch_raises_when_not_installed(self, strategy): + assert not strategy.isinstalled() + with pytest.raises(LaunchError) as excinfo: + strategy.launch() + assert 'Bootstrap classpath file does not exist' in str(excinfo.value) + + +# ----------------------------------------------------------------------- +# - Helpers - +# ----------------------------------------------------------------------- + +def config(conffile): + return ProjectConfig(CONFROOT.join(conffile).strpath) + + +def create_stub_assembly_jar(indir, projectconfig): + """Touches assembly jar file path in indir and returns the path.""" + scala_minor = projectconfig['scala-version'][:4] + name = 'ensime_{}-assembly.jar'.format(scala_minor) + return path.local(indir).ensure(name).realpath