diff --git a/LSP.sublime-settings b/LSP.sublime-settings
index baf277228..f86a52acf 100644
--- a/LSP.sublime-settings
+++ b/LSP.sublime-settings
@@ -220,6 +220,23 @@
// "region",
],
+ // --- LSP API-------------------------------------------------------------------------
+
+ // Specifies the type and priority of the Node.js installation that should be used for Node.js-based servers
+ // using the NpmClientHandler.
+ // The allowed values are:
+ // - 'system' - a Node.js runtime found on the PATH
+ // - 'local' - a Node.js runtime managed by LSP that doesn't affect the system
+ // The order in which the values are specified determines which one is tried first, with the later one being
+ // used as a fallback.
+ // You can also specify just a single value to disable the fallback.
+ "nodejs_runtime": ["system", "local"],
+
+ // Uses Node.js runtime from the Electron package rather than the official distribution. This has the benefit of
+ // lower memory usage due to it having the pointer compression (https://v8.dev/blog/pointer-compression) enabled.
+ // Only relevant when using `local` variant of `nodejs_runtime`.
+ "local_use_electron": false,
+
// --- Debugging ----------------------------------------------------------------------
// Show verbose debug messages in the sublime console.
diff --git a/Makefile b/Makefile
new file mode 100644
index 000000000..5e8b0baa0
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,21 @@
+# Minimal makefile for Sphinx documentation
+
+# You can set these variables from the command line.
+SOURCEDIR = api
+BUILDDIR = dist
+
+.PHONY: clean
+
+install:
+ pip3 install sphinx sphinx-rtd-theme sphinx-autodoc-typehints ghp-import mkdocs mkdocs-material mkdocs-redirects
+# sed -i -E 's/sublime\.DRAW_[A-Z_]*/0/g' source/modules/LSP/plugin/core/views.py
+# sed -i -E 's/sublime\.HIDE_ON_MINIMAP/0/g' source/modules/LSP/plugin/core/views.py
+
+build:
+ sphinx-build -M html "$(SOURCEDIR)" "$(BUILDDIR)"
+
+deploy:
+ ghp-import --no-jekyll --push --force html
+
+clean:
+ rm -rf doctrees html
diff --git a/api/__init__.py b/api/__init__.py
new file mode 100644
index 000000000..6bf5a6d72
--- /dev/null
+++ b/api/__init__.py
@@ -0,0 +1,25 @@
+from ._client_handler import ClientHandler
+from ._client_handler import notification_handler
+from ._client_handler import request_handler
+from .api_wrapper_interface import ApiWrapperInterface
+from .generic_client_handler import GenericClientHandler
+from .node_runtime import NodeRuntime
+from .npm_client_handler import NpmClientHandler
+from .server_npm_resource import ServerNpmResource
+from .server_pip_resource import ServerPipResource
+from .server_resource_interface import ServerResourceInterface
+from .server_resource_interface import ServerStatus
+
+__all__ = [
+ 'ApiWrapperInterface',
+ 'ClientHandler',
+ 'GenericClientHandler',
+ 'NodeRuntime',
+ 'NpmClientHandler',
+ 'ServerResourceInterface',
+ 'ServerStatus',
+ 'ServerNpmResource',
+ 'ServerPipResource',
+ 'notification_handler',
+ 'request_handler',
+]
diff --git a/api/_client_handler/__init__.py b/api/_client_handler/__init__.py
new file mode 100644
index 000000000..6c03556e2
--- /dev/null
+++ b/api/_client_handler/__init__.py
@@ -0,0 +1,9 @@
+from .abstract_plugin import ClientHandler
+from .api_decorator import notification_handler
+from .api_decorator import request_handler
+
+__all__ = [
+ 'ClientHandler',
+ 'notification_handler',
+ 'request_handler',
+]
diff --git a/api/_client_handler/abstract_plugin.py b/api/_client_handler/abstract_plugin.py
new file mode 100644
index 000000000..0016c9cc4
--- /dev/null
+++ b/api/_client_handler/abstract_plugin.py
@@ -0,0 +1,155 @@
+from .._util import weak_method
+from ..api_wrapper_interface import ApiWrapperInterface
+from ..server_resource_interface import ServerStatus
+from .api_decorator import register_decorated_handlers
+from .interface import ClientHandlerInterface
+from functools import partial
+from LSP.plugin import AbstractPlugin
+from LSP.plugin import ClientConfig
+from LSP.plugin import Notification
+from LSP.plugin import register_plugin
+from LSP.plugin import Request
+from LSP.plugin import Response
+from LSP.plugin import Session
+from LSP.plugin import unregister_plugin
+from LSP.plugin import WorkspaceFolder
+from LSP.plugin.core.rpc import method2attr
+from LSP.plugin.core.typing import Any, Callable, Dict, List, Optional, Tuple, TypedDict
+from os import path
+from weakref import ref
+import sublime
+
+__all__ = ['ClientHandler']
+
+LanguagesDict = TypedDict('LanguagesDict', {
+ 'document_selector': Optional[str],
+ 'languageId': Optional[str],
+ 'scopes': Optional[List[str]],
+ 'syntaxes': Optional[List[str]],
+}, total=False)
+ApiNotificationHandler = Callable[[Any], None]
+ApiRequestHandler = Callable[[Any, Callable[[Any], None]], None]
+
+
+class ApiWrapper(ApiWrapperInterface):
+ def __init__(self, plugin: 'ref[AbstractPlugin]'):
+ self.__plugin = plugin
+
+ def __session(self) -> Optional[Session]:
+ plugin = self.__plugin()
+ return plugin.weaksession() if plugin else None
+
+ # --- ApiWrapperInterface -----------------------------------------------------------------------------------------
+
+ def on_notification(self, method: str, handler: ApiNotificationHandler) -> None:
+ def handle_notification(weak_handler: ApiNotificationHandler, params: Any) -> None:
+ weak_handler(params)
+
+ plugin = self.__plugin()
+ if plugin:
+ setattr(plugin, method2attr(method), partial(handle_notification, weak_method(handler)))
+
+ def on_request(self, method: str, handler: ApiRequestHandler) -> None:
+ def send_response(request_id: Any, result: Any) -> None:
+ session = self.__session()
+ if session:
+ session.send_response(Response(request_id, result))
+
+ def on_response(weak_handler: ApiRequestHandler, params: Any, request_id: Any) -> None:
+ weak_handler(params, lambda result: send_response(request_id, result))
+
+ plugin = self.__plugin()
+ if plugin:
+ setattr(plugin, method2attr(method), partial(on_response, weak_method(handler)))
+
+ def send_notification(self, method: str, params: Any) -> None:
+ session = self.__session()
+ if session:
+ session.send_notification(Notification(method, params))
+
+ def send_request(self, method: str, params: Any, handler: Callable[[Any, bool], None]) -> None:
+ session = self.__session()
+ if session:
+ session.send_request(
+ Request(method, params), lambda result: handler(result, False), lambda result: handler(result, True))
+ else:
+ handler(None, True)
+
+
+class ClientHandler(AbstractPlugin, ClientHandlerInterface):
+ """
+ The base class for creating an LSP plugin.
+ """
+
+ # --- AbstractPlugin handlers -------------------------------------------------------------------------------------
+
+ @classmethod
+ def name(cls) -> str:
+ return cls.get_displayed_name()
+
+ @classmethod
+ def configuration(cls) -> Tuple[sublime.Settings, str]:
+ return cls.read_settings()
+
+ @classmethod
+ def additional_variables(cls) -> Dict[str, str]:
+ return cls.get_additional_variables()
+
+ @classmethod
+ def needs_update_or_installation(cls) -> bool:
+ if cls.manages_server():
+ server = cls.get_server()
+ return bool(server and server.needs_installation())
+ return False
+
+ @classmethod
+ def install_or_update(cls) -> None:
+ server = cls.get_server()
+ if server:
+ server.install_or_update()
+
+ @classmethod
+ def can_start(cls, window: sublime.Window, initiating_view: sublime.View,
+ workspace_folders: List[WorkspaceFolder], configuration: ClientConfig) -> Optional[str]:
+ if cls.manages_server():
+ server = cls.get_server()
+ if not server or server.get_status() == ServerStatus.ERROR:
+ return "{}: Error installing server dependencies.".format(cls.package_name)
+ if server.get_status() != ServerStatus.READY:
+ return "{}: Server installation in progress...".format(cls.package_name)
+ message = cls.is_allowed_to_start(window, initiating_view, workspace_folders, configuration)
+ if message:
+ return message
+ # Lazily update command after server has initialized if not set manually by the user.
+ if not configuration.command:
+ configuration.command = cls.get_command()
+ return None
+
+ @classmethod
+ def on_pre_start(cls, window: sublime.Window, initiating_view: sublime.View,
+ workspace_folders: List[WorkspaceFolder], configuration: ClientConfig) -> Optional[str]:
+ extra_paths = path.pathsep.join(cls.get_additional_paths())
+ if extra_paths:
+ original_path = configuration.env.get('PATH') or ''
+ if isinstance(original_path, list):
+ original_path = path.pathsep.join(original_path)
+ configuration.env['PATH'] = path.pathsep.join([extra_paths, original_path])
+ return None
+
+ # --- ClientHandlerInterface --------------------------------------------------------------------------------------
+
+ @classmethod
+ def setup(cls) -> None:
+ register_plugin(cls)
+
+ @classmethod
+ def cleanup(cls) -> None:
+ unregister_plugin(cls)
+
+ # --- Internals ---------------------------------------------------------------------------------------------------
+
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
+ super().__init__(*args, **kwargs)
+ api = ApiWrapper(ref(self)) # type: ignore
+ register_decorated_handlers(self, api)
+ self.on_ready(api)
diff --git a/api/_client_handler/api_decorator.py b/api/_client_handler/api_decorator.py
new file mode 100644
index 000000000..7af3b31e1
--- /dev/null
+++ b/api/_client_handler/api_decorator.py
@@ -0,0 +1,86 @@
+from ..api_wrapper_interface import ApiWrapperInterface
+from .interface import ClientHandlerInterface
+from LSP.plugin.core.typing import Any, Callable, List, Optional, TypeVar, Union
+import inspect
+
+__all__ = [
+ "notification_handler",
+ "request_handler",
+ "register_decorated_handlers",
+]
+
+T = TypeVar('T')
+# the first argument is always "self"
+NotificationHandler = Callable[[Any, Any], None]
+RequestHandler = Callable[[Any, Any, Callable[[Any], None]], None]
+MessageMethods = Union[str, List[str]]
+
+_HANDLER_MARKS = {
+ "notification": "__handle_notification_message_methods",
+ "request": "__handle_request_message_methods",
+}
+
+
+def notification_handler(notification_methods: MessageMethods) -> Callable[[NotificationHandler], NotificationHandler]:
+ """
+ Marks the decorated function as a "notification" message handler.
+
+ On server sending the notification, the decorated function will be called with the `params` argument which contains
+ the payload.
+ """
+
+ return _create_handler("notification", notification_methods)
+
+
+def request_handler(request_methods: MessageMethods) -> Callable[[RequestHandler], RequestHandler]:
+ """
+ Marks the decorated function as a "request" message handler.
+
+ On server sending the request, the decorated function will be called with two arguments (`params` and `respond`).
+ The first argument (`params`) is the payload of the request and the second argument (`respond`) is the function that
+ must be used to respond to the request. The `respond` function takes any data that should be sent back to the
+ server.
+ """
+
+ return _create_handler("request", request_methods)
+
+
+def _create_handler(client_event: str, message_methods: MessageMethods) -> Callable[[T], T]:
+ """ Marks the decorated function as a message handler. """
+
+ message_methods = [message_methods] if isinstance(message_methods, str) else message_methods
+
+ def decorator(func: T) -> T:
+ setattr(func, _HANDLER_MARKS[client_event], message_methods)
+ return func
+
+ return decorator
+
+
+def register_decorated_handlers(client_handler: ClientHandlerInterface, api: ApiWrapperInterface) -> None:
+ """
+ Register decorator-style custom message handlers.
+
+ This method works as following:
+
+ 1. Scan through all methods of `client_handler`.
+ 2. If a method is decorated, it will have a "handler mark" attribute which is set by the decorator.
+ 3. Register the method with wanted message methods, which are stored in the "handler mark" attribute.
+
+ :param client_handler: The instance of the client handler.
+ :param api: The API instance for interacting with the server.
+ """
+ for _, func in inspect.getmembers(client_handler, predicate=inspect.isroutine):
+ for client_event, handler_mark in _HANDLER_MARKS.items():
+ message_methods = getattr(func, handler_mark, None) # type: Optional[List[str]]
+ if message_methods is None:
+ continue
+
+ event_registrator = getattr(api, "on_" + client_event, None)
+ if callable(event_registrator):
+ for message_method in message_methods:
+ event_registrator(message_method, func)
+
+ # it makes no sense that a handler handles both "notification" and "request"
+ # so we do early break once we've registered a handler
+ break
diff --git a/api/_client_handler/interface.py b/api/_client_handler/interface.py
new file mode 100644
index 000000000..151fda870
--- /dev/null
+++ b/api/_client_handler/interface.py
@@ -0,0 +1,99 @@
+from ..api_wrapper_interface import ApiWrapperInterface
+from ..server_resource_interface import ServerResourceInterface
+from abc import ABCMeta
+from abc import abstractmethod
+from LSP.plugin import ClientConfig
+from LSP.plugin import DottedDict
+from LSP.plugin import WorkspaceFolder
+from LSP.plugin.core.typing import Dict, List, Optional, Tuple
+import sublime
+
+__all__ = ['ClientHandlerInterface']
+
+
+class ClientHandlerInterface(metaclass=ABCMeta):
+ package_name = ''
+
+ @classmethod
+ @abstractmethod
+ def setup(cls) -> None:
+ ...
+
+ @classmethod
+ @abstractmethod
+ def cleanup(cls) -> None:
+ ...
+
+ @classmethod
+ @abstractmethod
+ def get_displayed_name(cls) -> str:
+ ...
+
+ @classmethod
+ @abstractmethod
+ def package_storage(cls) -> str:
+ ...
+
+ @classmethod
+ @abstractmethod
+ def get_additional_variables(cls) -> Dict[str, str]:
+ ...
+
+ @classmethod
+ @abstractmethod
+ def get_additional_paths(cls) -> List[str]:
+ ...
+
+ @classmethod
+ @abstractmethod
+ def manages_server(cls) -> bool:
+ ...
+
+ @classmethod
+ @abstractmethod
+ def get_command(cls) -> List[str]:
+ ...
+
+ @classmethod
+ @abstractmethod
+ def binary_path(cls) -> str:
+ ...
+
+ @classmethod
+ @abstractmethod
+ def get_server(cls) -> Optional[ServerResourceInterface]:
+ ...
+
+ @classmethod
+ @abstractmethod
+ def get_binary_arguments(cls) -> List[str]:
+ ...
+
+ @classmethod
+ @abstractmethod
+ def read_settings(cls) -> Tuple[sublime.Settings, str]:
+ ...
+
+ @classmethod
+ @abstractmethod
+ def on_settings_read(cls, settings: sublime.Settings) -> bool:
+ ...
+
+ @classmethod
+ @abstractmethod
+ def is_allowed_to_start(
+ cls,
+ window: sublime.Window,
+ initiating_view: Optional[sublime.View] = None,
+ workspace_folders: Optional[List[WorkspaceFolder]] = None,
+ configuration: Optional[ClientConfig] = None
+ ) -> Optional[str]:
+ ...
+
+ @abstractmethod
+ def on_ready(self, api: ApiWrapperInterface) -> None:
+ ...
+
+ @abstractmethod
+ def on_settings_changed(self, settings: DottedDict) -> None:
+ ...
diff --git a/api/_util/__init__.py b/api/_util/__init__.py
new file mode 100644
index 000000000..7eda31c72
--- /dev/null
+++ b/api/_util/__init__.py
@@ -0,0 +1,5 @@
+from .weak_method import weak_method
+
+__all__ = [
+ 'weak_method',
+]
diff --git a/api/_util/weak_method.py b/api/_util/weak_method.py
new file mode 100644
index 000000000..ff3bbdb2d
--- /dev/null
+++ b/api/_util/weak_method.py
@@ -0,0 +1,34 @@
+from LSP.plugin.core.typing import Any, Callable
+from types import MethodType
+import weakref
+
+
+__all__ = ['weak_method']
+
+
+# An implementation of weak method borrowed from sublime_lib [1]
+#
+# We need it to be able to weak reference bound methods as `weakref.WeakMethod` is not available in
+# 3.3 runtime.
+#
+# The reason this is necessary is explained in the documentation of `weakref.WeakMethod`:
+# > A custom ref subclass which simulates a weak reference to a bound method (i.e., a method defined
+# > on a class and looked up on an instance). Since a bound method is ephemeral, a standard weak
+# > reference cannot keep hold of it.
+#
+# [1] https://github.com/SublimeText/sublime_lib/blob/master/st3/sublime_lib/_util/weak_method.py
+
+def weak_method(method: Callable[..., Any]) -> Callable[..., Any]:
+ assert isinstance(method, MethodType)
+ self_ref = weakref.ref(method.__self__)
+ function_ref = weakref.ref(method.__func__)
+
+ def wrapped(*args: Any, **kwargs: Any) -> Any:
+ self = self_ref()
+ fn = function_ref()
+ if self is None or fn is None:
+ print('[LSP.api] Error: weak_method not called due to a deleted reference', [self, fn])
+ return
+ return fn(self, *args, **kwargs) # type: ignore
+
+ return wrapped
diff --git a/api/api_wrapper_interface.py b/api/api_wrapper_interface.py
new file mode 100644
index 000000000..e13a2687b
--- /dev/null
+++ b/api/api_wrapper_interface.py
@@ -0,0 +1,46 @@
+from abc import ABCMeta, abstractmethod
+from LSP.plugin.core.typing import Any, Callable
+
+__all__ = ['ApiWrapperInterface']
+
+
+NotificationHandler = Callable[[Any], None]
+RequestHandler = Callable[[Any, Callable[[Any], None]], None]
+
+
+class ApiWrapperInterface(metaclass=ABCMeta):
+ """
+ An interface for sending and receiving requests and notifications from and to the server. An implementation of it
+ is available through the :func:`GenericClientHandler.on_ready()` override.
+ """
+
+ @abstractmethod
+ def on_notification(self, method: str, handler: NotificationHandler) -> None:
+ """
+ Registers a handler for given notification name. The handler will be called with optional params.
+ """
+ ...
+
+ @abstractmethod
+ def on_request(self, method: str, handler: RequestHandler) -> None:
+ """
+ Registers a handler for given request name. The handler will be called with two arguments - first the params
+ sent with the request and second the function that must be used to respond to the request. The response
+ function takes params to respond with.
+ """
+ ...
+
+ @abstractmethod
+ def send_notification(self, method: str, params: Any) -> None:
+ """
+ Sends a notification to the server.
+ """
+ ...
+
+ @abstractmethod
+ def send_request(self, method: str, params: Any, handler: Callable[[Any, bool], None]) -> None:
+ """
+ Sends a request to the server. The handler will be called with the result received from the server and
+ a boolean value `False` if request has succeeded and `True` if it returned an error.
+ """
+ ...
diff --git a/api/generic_client_handler.py b/api/generic_client_handler.py
new file mode 100644
index 000000000..8e86c43b4
--- /dev/null
+++ b/api/generic_client_handler.py
@@ -0,0 +1,204 @@
+from ._client_handler import ClientHandler
+from .api_wrapper_interface import ApiWrapperInterface
+from .helpers import rmtree_ex
+from .server_resource_interface import ServerResourceInterface
+from abc import ABCMeta
+from LSP.plugin import ClientConfig
+from LSP.plugin import DottedDict
+from LSP.plugin import WorkspaceFolder
+from LSP.plugin.core.typing import Any, Dict, List, Optional, Tuple
+from package_control import events # type: ignore
+import os
+import sublime
+
+__all__ = ['GenericClientHandler']
+
+
+class GenericClientHandler(ClientHandler, metaclass=ABCMeta):
+ """
+ An generic implementation of an LSP plugin handler.
+ """
+
+ package_name = ''
+ """
+ The name of the released package. Also used for the name of the LSP client and for reading package settings.
+
+ This name must be set and must match the basename of the corresponding `*.sublime-settings` file.
+ It's also used as a directory name for package storage when implementing a server resource interface.
+ Recommended to use `__package__` value fo this one. If you need to override handler name in the UI,
+ override :meth:`get_displayed_name()` also.
+
+ :required: Yes
+ """
+
+ # --- ClientHandler handlers --------------------------------------------------------------------------------------
+
+ @classmethod
+ def setup(cls) -> None:
+ if not cls.package_name:
+ raise Exception('ERROR: [LSP.api] package_name is required to instantiate an instance of {}'.format(cls))
+ super().setup()
+
+ @classmethod
+ def cleanup(cls) -> None:
+
+ def run_async() -> None:
+ if os.path.isdir(cls.package_storage()):
+ rmtree_ex(cls.package_storage())
+
+ if events.remove(cls.package_name):
+ sublime.set_timeout_async(run_async, 1000)
+ super().cleanup()
+
+ @classmethod
+ def get_displayed_name(cls) -> str:
+ """
+ Returns the name that will be shown in the ST UI (for example in the status field).
+
+ Defaults to the value of :attr:`package_name`.
+ """
+ return cls.package_name
+
+ @classmethod
+ def storage_path(cls) -> str:
+ """
+ The storage path. Use this as your base directory to install server files. Its path is '$DATA/Package Storage'.
+ """
+ return super().storage_path()
+
+ @classmethod
+ def package_storage(cls) -> str:
+ """
+ The storage path for this package. Its path is '$DATA/Package Storage/[Package_Name]'.
+ """
+ return os.path.join(cls.storage_path(), cls.package_name)
+
+ @classmethod
+ def get_command(cls) -> List[str]:
+ """
+ Returns a list of arguments to use to start the server. The default implementation returns combined result of
+ :meth:`binary_path()` and :meth:`get_binary_arguments()`.
+ """
+ return [cls.binary_path()] + cls.get_binary_arguments()
+
+ @classmethod
+ def binary_path(cls) -> str:
+ """
+ The filesystem path to the server executable.
+
+ The default implementation returns `binary_path` property of the server instance (returned from
+ :meth:`get_server()`), if available.
+ """
+ if cls.manages_server():
+ server = cls.get_server()
+ if server:
+ return server.binary_path
+ return ''
+
+ @classmethod
+ def get_binary_arguments(cls) -> List[str]:
+ """
+ Returns a list of extra arguments to append to the `command` when starting the server.
+
+ See :meth:`get_command()`.
+ """
+ return []
+
+ @classmethod
+ def read_settings(cls) -> Tuple[sublime.Settings, str]:
+ filename = "{}.sublime-settings".format(cls.package_name)
+ loaded_settings = sublime.load_settings(filename)
+ changed = cls.on_settings_read(loaded_settings)
+ if changed:
+ sublime.save_settings(filename)
+ filepath = "Packages/{}/{}".format(cls.package_name, filename)
+ return (loaded_settings, filepath)
+
+ @classmethod
+ def get_additional_variables(cls) -> Dict[str, str]:
+ """
+ Override to add more variables here to be expanded when reading settings.
+
+ Default implementation adds a `${server_path}` variable that holds filesystem path to the server
+ binary (only when :meth:`manages_server` is `True`).
+
+ Remember to call the super class and merge the results if overriding.
+ """
+ return {
+ 'pathsep': os.pathsep,
+ 'server_path': cls.binary_path(),
+ }
+
+ @classmethod
+ def get_additional_paths(cls) -> List[str]:
+ """
+ Override to prepend additional paths to the default PATH environment variable.
+
+ Remember to call the super class and merge the results if overriding.
+ """
+ return []
+
+ @classmethod
+ def manages_server(cls) -> bool:
+ """
+ Whether this handler manages a server. If the response is `True` then the :meth:`get_server()` should also be
+ implemented.
+ """
+ return False
+
+ @classmethod
+ def get_server(cls) -> Optional[ServerResourceInterface]:
+ """
+ :returns: The instance of the server managed by this plugin. Only used when :meth:`manages_server()`
+ returns `True`.
+ """
+ return None
+
+ @classmethod
+ def on_settings_read(cls, settings: sublime.Settings) -> bool:
+ """
+ Called when package settings were read. Receives a `sublime.Settings` object.
+
+ It's recommended to use :meth:`on_settings_changed()` instead if you don't need to persistent your changes to
+ the disk.
+
+ :returns: `True` to save modifications back into the settings file.
+ """
+ return False
+
+ @classmethod
+ def is_allowed_to_start(
+ cls,
+ window: sublime.Window,
+ initiating_view: Optional[sublime.View] = None,
+ workspace_folders: Optional[List[WorkspaceFolder]] = None,
+ configuration: Optional[ClientConfig] = None
+ ) -> Optional[str]:
+ """
+ Determines if the session is allowed to start.
+
+ :returns: A string describing the reason why we should not start a language server session, or `None` if we
+ should go ahead and start a session.
+ """
+ return None
+
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
+ # Seems unnecessary to override but it's to hide the original argument from the documentation.
+ super().__init__(*args, **kwargs)
+
+ def on_ready(self, api: ApiWrapperInterface) -> None:
+ """
+ Called when the instance is ready.
+
+ :param api: The API instance for interacting with the server.
+ """
+ pass
+
+ def on_settings_changed(self, settings: DottedDict) -> None:
+ """
+ Override this method to alter the settings that are returned to the server for the
+ workspace/didChangeConfiguration notification and the workspace/configuration requests.
+
+ :param settings: The settings that the server should receive.
+ """
+ pass
diff --git a/api/helpers.py b/api/helpers.py
new file mode 100644
index 000000000..c87f1df39
--- /dev/null
+++ b/api/helpers.py
@@ -0,0 +1,95 @@
+from LSP.plugin.core.typing import Any, Callable, Dict, List, Optional, Tuple
+import os
+import shutil
+import sublime
+import subprocess
+import threading
+
+StringCallback = Callable[[str], None]
+SemanticVersion = Tuple[int, int, int]
+
+is_windows = sublime.platform() == 'windows'
+
+
+def run_command_sync(
+ args: List[str],
+ cwd: Optional[str] = None,
+ extra_env: Optional[Dict[str, str]] = None,
+ extra_paths: List[str] = [],
+ shell: bool = is_windows,
+) -> Tuple[str, Optional[str]]:
+ """
+ Runs the given command synchronously.
+
+ :returns: A two-element tuple with the returned value and an optional error. If running the command has failed, the
+ first tuple element will be empty string and the second will contain the potential `stderr` output. If the
+ command has succeeded then the second tuple element will be `None`.
+ """
+ try:
+ env = None
+ if extra_env or extra_paths:
+ env = os.environ.copy()
+ if extra_env:
+ env.update(extra_env)
+ if extra_paths:
+ env['PATH'] = os.path.pathsep.join(extra_paths) + os.path.pathsep + env['PATH']
+ startupinfo = None
+ if is_windows:
+ startupinfo = subprocess.STARTUPINFO() # type: ignore
+ startupinfo.dwFlags |= subprocess.SW_HIDE | subprocess.STARTF_USESHOWWINDOW # type: ignore
+ output = subprocess.check_output(
+ args, cwd=cwd, shell=shell, stderr=subprocess.STDOUT, env=env, startupinfo=startupinfo)
+ return (decode_bytes(output).strip(), None)
+ except subprocess.CalledProcessError as error:
+ return ('', decode_bytes(error.output).strip())
+
+
+def run_command_async(args: List[str], on_success: StringCallback, on_error: StringCallback, **kwargs: Any) -> None:
+ """
+ Runs the given command asynchronously.
+
+ On success calls the provided `on_success` callback with the value the the command has returned.
+ On error calls the provided `on_error` callback with the potential `stderr` output.
+ """
+
+ def execute(on_success: StringCallback, on_error: StringCallback, args: List[str]) -> None:
+ result, error = run_command_sync(args, **kwargs)
+ on_error(error) if error is not None else on_success(result)
+
+ thread = threading.Thread(target=execute, args=(on_success, on_error, args))
+ thread.start()
+
+
+def decode_bytes(data: bytes) -> str:
+ """
+ Decodes provided bytes using `utf-8` decoding, ignoring potential decoding errors.
+ """
+ return data.decode('utf-8', 'ignore')
+
+
+def rmtree_ex(path: str, ignore_errors: bool = False) -> None:
+ # On Windows, "shutil.rmtree" will raise file not found errors when deleting a long path (>255 chars).
+ # See https://stackoverflow.com/a/14076169/4643765
+ # See https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation
+ path = R'\\?\{}'.format(path) if sublime.platform() == 'windows' else path
+ shutil.rmtree(path, ignore_errors)
+
+
+def version_to_string(version: SemanticVersion) -> str:
+ """
+ Returns a string representation of a version tuple.
+ """
+ return '.'.join([str(c) for c in version])
+
+
+def log_and_show_message(message: str, additional_logs: Optional[str] = None, show_in_status: bool = True) -> None:
+ """
+ Logs the message in the console and optionally sets it as a status message on the window.
+
+ :param message: The message to log or show in the status.
+ :param additional_logs: The extra value to log on a separate line.
+ :param show_in_status: Whether to briefly show the message in the status bar of the current window.
+ """
+ print(message, '\n', additional_logs) if additional_logs else print(message)
+ if show_in_status:
+ sublime.active_window().status_message(message)
diff --git a/api/node_runtime.py b/api/node_runtime.py
new file mode 100755
index 000000000..cadd7b97c
--- /dev/null
+++ b/api/node_runtime.py
@@ -0,0 +1,511 @@
+from .helpers import rmtree_ex
+from .helpers import run_command_sync
+from .helpers import SemanticVersion
+from .helpers import version_to_string
+from contextlib import contextmanager
+from LSP.plugin.core.constants import SUBLIME_SETTINGS_FILENAME
+from LSP.plugin.core.logging import debug
+from LSP.plugin.core.typing import cast, Any, Dict, Generator, List, Optional, Tuple, Union
+from LSP.third_party.semantic_version import NpmSpec, Version
+from os import path
+from os import remove
+from sublime_lib import ActivityIndicator
+import os
+import shutil
+import sublime
+import subprocess
+import sys
+import tarfile
+import urllib.request
+import zipfile
+
+__all__ = ['NodeRuntime']
+
+IS_WINDOWS_7_OR_LOWER = sys.platform == 'win32' and sys.getwindowsversion()[:2] <= (6, 1) # type: ignore
+
+NODE_RUNTIME_VERSION = '18.18.1'
+NODE_DIST_URL = 'https://nodejs.org/dist/v{version}/{filename}'
+
+ELECTRON_RUNTIME_VERSION = '27.0.0' # includes Node.js v18.17.1
+ELECTRON_NODE_VERSION = '18.17.1'
+ELECTRON_DIST_URL = 'https://github.com/electron/electron/releases/download/v{version}/{filename}'
+YARN_URL = 'https://github.com/yarnpkg/yarn/releases/download/v1.22.21/yarn-1.22.21.js'
+
+NO_NODE_FOUND_MESSAGE = 'Could not start {package_name} due to not being able to resolve suitable Node.js \
+runtime on the PATH. Press the "Download Node.js" button to get required Node.js version \
+(note that it will be used only by LSP and will not affect your system otherwise).'
+
+
+class NodeRuntime:
+ _node_runtime_resolved = False
+ _node_runtime = None # Optional[NodeRuntime]
+ """
+ Cached instance of resolved Node.js runtime. This is only done once per-session to avoid unnecessary IO.
+ """
+
+ @classmethod
+ def get(
+ cls, package_name: str, storage_path: str, required_node_version: Union[str, SemanticVersion]
+ ) -> Optional['NodeRuntime']:
+ if isinstance(required_node_version, tuple):
+ required_semantic_version = NpmSpec('>={}'.format(version_to_string(required_node_version)))
+ else:
+ required_semantic_version = NpmSpec(required_node_version)
+ if cls._node_runtime_resolved:
+ if cls._node_runtime:
+ cls._node_runtime.check_satisfies_version(required_semantic_version)
+ return cls._node_runtime
+ cls._node_runtime_resolved = True
+ cls._node_runtime = cls._resolve_node_runtime(package_name, storage_path, required_semantic_version)
+ debug('Resolved Node.js Runtime for package {}: {}'.format(package_name, cls._node_runtime))
+ return cls._node_runtime
+
+ @classmethod
+ def _resolve_node_runtime(
+ cls, package_name: str, storage_path: str, required_node_version: NpmSpec
+ ) -> 'NodeRuntime':
+ resolved_runtime = None # type: Optional[NodeRuntime]
+ default_runtimes = ['system', 'local']
+ settings = sublime.load_settings(SUBLIME_SETTINGS_FILENAME)
+ selected_runtimes = cast(List[str], settings.get('nodejs_runtime') or default_runtimes)
+ log_lines = ['--- LSP.api Node.js resolving start ---']
+ for runtime_type in selected_runtimes:
+ if runtime_type == 'system':
+ log_lines.append('Resolving Node.js Runtime in env PATH for package {}...'.format(package_name))
+ path_runtime = NodeRuntimePATH()
+ try:
+ path_runtime.check_binary_present()
+ except Exception as ex:
+ log_lines.append(' * Failed: {}'.format(ex))
+ continue
+ try:
+ path_runtime.check_satisfies_version(required_node_version)
+ resolved_runtime = path_runtime
+ break
+ except Exception as ex:
+ log_lines.append(' * {}'.format(ex))
+ elif runtime_type == 'local':
+ log_lines.append('Resolving Node.js Runtime from LSP.api for package {}...'.format(package_name))
+ use_electron = cast(bool, settings.get('local_use_electron') or False)
+ runtime_dir = path.join(storage_path, 'LSP', 'node-runtime')
+ local_runtime = ElectronRuntimeLocal(runtime_dir) if use_electron else NodeRuntimeLocal(runtime_dir)
+ try:
+ local_runtime.check_binary_present()
+ except Exception as ex:
+ log_lines.append(' * Binaries check failed: {}'.format(ex))
+ if selected_runtimes[0] != 'local':
+ if not sublime.ok_cancel_dialog(
+ NO_NODE_FOUND_MESSAGE.format(package_name=package_name), 'Download Node.js'):
+ log_lines.append(' * Download skipped')
+ continue
+ # Remove outdated runtimes.
+ if path.isdir(runtime_dir):
+ for directory in next(os.walk(runtime_dir))[1]:
+ old_dir = path.join(runtime_dir, directory)
+ print('[LSP.api] Deleting outdated Node.js runtime directory "{}"'.format(old_dir))
+ try:
+ rmtree_ex(old_dir)
+ except Exception as ex:
+ log_lines.append(' * Failed deleting: {}'.format(ex))
+ try:
+ local_runtime.install_node()
+ except Exception as ex:
+ log_lines.append(' * Failed downloading: {}'.format(ex))
+ continue
+ try:
+ local_runtime.check_binary_present()
+ except Exception as ex:
+ log_lines.append(' * Failed: {}'.format(ex))
+ continue
+ try:
+ local_runtime.check_satisfies_version(required_node_version)
+ resolved_runtime = local_runtime
+ break
+ except Exception as ex:
+ log_lines.append(' * {}'.format(ex))
+ if not resolved_runtime:
+ log_lines.append('--- LSP.api Node.js resolving end ---')
+ print('\n'.join(log_lines))
+ raise Exception('Failed resolving Node.js Runtime. Please check in the console for more details.')
+ return resolved_runtime
+
+ def __init__(self) -> None:
+ self._node = None # type: Optional[str]
+ self._npm = None # type: Optional[str]
+ self._version = None # type: Optional[Version]
+ self._additional_paths = [] # type: List[str]
+
+ def __repr__(self) -> str:
+ return '{}(node: {}, npm: {}, version: {})'.format(
+ self.__class__.__name__, self._node, self._npm, self._version if self._version else None)
+
+ def install_node(self) -> None:
+ raise Exception('Not supported!')
+
+ def node_bin(self) -> Optional[str]:
+ return self._node
+
+ def npm_bin(self) -> Optional[str]:
+ return self._npm
+
+ def node_env(self) -> Dict[str, str]:
+ if IS_WINDOWS_7_OR_LOWER:
+ return {'NODE_SKIP_PLATFORM_CHECK': '1'}
+ return {}
+
+ def check_binary_present(self) -> None:
+ if self._node is None:
+ raise Exception('"node" binary not found')
+ if self._npm is None:
+ raise Exception('"npm" binary not found')
+
+ def check_satisfies_version(self, required_node_version: NpmSpec) -> None:
+ node_version = self.resolve_version()
+ if node_version not in required_node_version:
+ raise Exception(
+ 'Node.js version requirement failed. Expected {}, got {}.'.format(required_node_version, node_version))
+
+ def resolve_version(self) -> Version:
+ if self._version:
+ return self._version
+ if not self._node:
+ raise Exception('Node.js not initialized')
+ # In this case we have fully resolved binary path already so shouldn't need `shell` on Windows.
+ version, error = run_command_sync([self._node, '--version'], extra_env=self.node_env(), shell=False)
+ if error is None:
+ self._version = Version(version.replace('v', ''))
+ else:
+ raise Exception('Failed resolving Node.js version. Error:\n{}'.format(error))
+ return self._version
+
+ def run_node(
+ self,
+ args: List[str],
+ stdin: int = subprocess.PIPE,
+ stdout: int = subprocess.PIPE,
+ stderr: int = subprocess.PIPE,
+ env: Dict[str, Any] = {}
+ ) -> Optional['subprocess.Popen[bytes]']:
+ node_bin = self.node_bin()
+ if node_bin is None:
+ return None
+ os_env = os.environ.copy()
+ os_env.update(self.node_env())
+ os_env.update(env)
+ startupinfo = None
+ if sys.platform == 'win32':
+ startupinfo = subprocess.STARTUPINFO()
+ startupinfo.dwFlags |= subprocess.SW_HIDE | subprocess.STARTF_USESHOWWINDOW
+ return subprocess.Popen(
+ [node_bin] + args, stdin=stdin, stdout=stdout, stderr=stderr, env=os_env, startupinfo=startupinfo)
+
+ def run_install(self, cwd: str) -> None:
+ if not path.isdir(cwd):
+ raise Exception('Specified working directory "{}" does not exist'.format(cwd))
+ if not self._node:
+ raise Exception('Node.js not installed. Use NodeInstaller to install it first.')
+ args = [
+ 'ci',
+ '--omit=dev',
+ '--scripts-prepend-node-path=true',
+ '--verbose',
+ ]
+ stdout, error = run_command_sync(
+ self.npm_command() + args, cwd=cwd, extra_env=self.node_env(), extra_paths=self._additional_paths,
+ shell=False
+ )
+ print('[LSP.api] START output of command: "{}"'.format(' '.join(args)))
+ print(stdout)
+ print('[LSP.api] Command output END')
+ if error is not None:
+ raise Exception('Failed to run npm command "{}":\n{}'.format(' '.join(args), error))
+
+ def npm_command(self) -> List[str]:
+ if self._npm is None:
+ raise Exception('Npm command not initialized')
+ return [self._npm]
+
+
+class NodeRuntimePATH(NodeRuntime):
+ def __init__(self) -> None:
+ super().__init__()
+ self._node = shutil.which('node')
+ self._npm = shutil.which('npm')
+
+
+class NodeRuntimeLocal(NodeRuntime):
+ def __init__(self, base_dir: str, node_version: str = NODE_RUNTIME_VERSION):
+ super().__init__()
+ self._base_dir = path.abspath(path.join(base_dir, node_version))
+ self._node_version = node_version
+ self._node_dir = path.join(self._base_dir, 'node')
+ self._install_in_progress_marker_file = path.join(self._base_dir, '.installing')
+ self._resolve_paths()
+
+ # --- NodeRuntime overrides ----------------------------------------------------------------------------------------
+
+ def npm_command(self) -> List[str]:
+ if not self._node or not self._npm:
+ raise Exception('Node.js or Npm command not initialized')
+ return [self._node, self._npm]
+
+ def install_node(self) -> None:
+ os.makedirs(os.path.dirname(self._install_in_progress_marker_file), exist_ok=True)
+ open(self._install_in_progress_marker_file, 'a').close()
+ with ActivityIndicator(sublime.active_window(), 'Downloading Node.js'):
+ install_node = NodeInstaller(self._base_dir, self._node_version)
+ install_node.run()
+ self._resolve_paths()
+ remove(self._install_in_progress_marker_file)
+ self._resolve_paths()
+
+ # --- private methods ----------------------------------------------------------------------------------------------
+
+ def _resolve_paths(self) -> None:
+ if path.isfile(self._install_in_progress_marker_file):
+ # Will trigger re-installation.
+ return
+ self._node = self._resolve_binary()
+ self._node_lib = self._resolve_lib()
+ self._npm = path.join(self._node_lib, 'npm', 'bin', 'npm-cli.js')
+ self._additional_paths = [path.dirname(self._node)] if self._node else []
+
+ def _resolve_binary(self) -> Optional[str]:
+ exe_path = path.join(self._node_dir, 'node.exe')
+ binary_path = path.join(self._node_dir, 'bin', 'node')
+ if path.isfile(exe_path):
+ return exe_path
+ if path.isfile(binary_path):
+ return binary_path
+ return None
+
+ def _resolve_lib(self) -> str:
+ lib_path = path.join(self._node_dir, 'lib', 'node_modules')
+ if not path.isdir(lib_path):
+ lib_path = path.join(self._node_dir, 'node_modules')
+ return lib_path
+
+
+class NodeInstaller:
+ '''Command to install a local copy of Node.js'''
+
+ def __init__(self, base_dir: str, node_version: str = NODE_RUNTIME_VERSION) -> None:
+ """
+ :param base_dir: The base directory for storing given Node.js runtime version
+ :param node_version: The Node.js version to install
+ """
+ self._base_dir = base_dir
+ self._node_version = node_version
+ self._cache_dir = path.join(self._base_dir, 'cache')
+
+ def run(self) -> None:
+ archive, url = self._node_archive()
+ print('[LSP.api] Downloading Node.js {} from {}'.format(self._node_version, url))
+ if not self._archive_exists(archive):
+ self._download_node(url, archive)
+ self._install_node(archive)
+
+ def _node_archive(self) -> Tuple[str, str]:
+ platform = sublime.platform()
+ arch = sublime.arch()
+ if platform == 'windows' and arch == 'x64':
+ node_os = 'win'
+ archive = 'zip'
+ elif platform == 'linux':
+ node_os = 'linux'
+ archive = 'tar.gz'
+ elif platform == 'osx':
+ node_os = 'darwin'
+ archive = 'tar.gz'
+ else:
+ raise Exception('{} {} is not supported'.format(arch, platform))
+ filename = 'node-v{}-{}-{}.{}'.format(self._node_version, node_os, arch, archive)
+ dist_url = NODE_DIST_URL.format(version=self._node_version, filename=filename)
+ return filename, dist_url
+
+ def _archive_exists(self, filename: str) -> bool:
+ archive = path.join(self._cache_dir, filename)
+ return path.isfile(archive)
+
+ def _download_node(self, url: str, filename: str) -> None:
+ if not path.isdir(self._cache_dir):
+ os.makedirs(self._cache_dir)
+ archive = path.join(self._cache_dir, filename)
+ with urllib.request.urlopen(url) as response:
+ with open(archive, 'wb') as f:
+ shutil.copyfileobj(response, f)
+
+ def _install_node(self, filename: str) -> None:
+ archive = path.join(self._cache_dir, filename)
+ opener = zipfile.ZipFile if filename.endswith('.zip') else tarfile.open # type: Any
+ try:
+ with opener(archive) as f:
+ names = f.namelist() if hasattr(f, 'namelist') else f.getnames()
+ install_dir, _ = next(x for x in names if '/' in x).split('/', 1)
+ bad_members = [x for x in names if x.startswith('/') or x.startswith('..')]
+ if bad_members:
+ raise Exception('{} appears to be malicious, bad filenames: {}'.format(filename, bad_members))
+ f.extractall(self._base_dir)
+ with chdir(self._base_dir):
+ os.rename(install_dir, 'node')
+ except Exception as ex:
+ raise ex
+ finally:
+ remove(archive)
+
+
+class ElectronRuntimeLocal(NodeRuntime):
+ def __init__(self, base_dir: str):
+ super().__init__()
+ self._base_dir = path.abspath(path.join(base_dir, ELECTRON_NODE_VERSION))
+ self._yarn = path.join(self._base_dir, 'yarn.js')
+ self._install_in_progress_marker_file = path.join(self._base_dir, '.installing')
+ if not path.isfile(self._install_in_progress_marker_file):
+ self._resolve_paths()
+
+ # --- NodeRuntime overrides ----------------------------------------------------------------------------------------
+
+ def node_env(self) -> Dict[str, str]:
+ extra_env = super().node_env()
+ extra_env.update({'ELECTRON_RUN_AS_NODE': 'true'})
+ return extra_env
+
+ def install_node(self) -> None:
+ os.makedirs(os.path.dirname(self._install_in_progress_marker_file), exist_ok=True)
+ open(self._install_in_progress_marker_file, 'a').close()
+ with ActivityIndicator(sublime.active_window(), 'Downloading Node.js'):
+ install_node = ElectronInstaller(self._base_dir)
+ install_node.run()
+ self._resolve_paths()
+ remove(self._install_in_progress_marker_file)
+
+ def run_install(self, cwd: str) -> None:
+ self._run_yarn(['import'], cwd)
+ args = [
+ 'install',
+ '--production',
+ '--frozen-lockfile',
+ '--scripts-prepend-node-path=true',
+ '--cache-folder={}'.format(path.join(self._base_dir, 'cache', 'yarn')),
+ # '--verbose',
+ ]
+ self._run_yarn(args, cwd)
+
+ # --- private methods ----------------------------------------------------------------------------------------------
+
+ def _resolve_paths(self) -> None:
+ self._node = self._resolve_binary()
+ self._npm = path.join(self._base_dir, 'yarn.js')
+
+ def _resolve_binary(self) -> Optional[str]:
+ binary_path = None
+ platform = sublime.platform()
+ if platform == 'osx':
+ binary_path = path.join(self._base_dir, 'Electron.app', 'Contents', 'MacOS', 'Electron')
+ elif platform == 'windows':
+ binary_path = path.join(self._base_dir, 'electron.exe')
+ else:
+ binary_path = path.join(self._base_dir, 'electron')
+ return binary_path if binary_path and path.isfile(binary_path) else None
+
+ def _run_yarn(self, args: List[str], cwd: str) -> None:
+ if not path.isdir(cwd):
+ raise Exception('Specified working directory "{}" does not exist'.format(cwd))
+ if not self._node:
+ raise Exception('Node.js not installed. Use NodeInstaller to install it first.')
+ stdout, error = run_command_sync(
+ [self._node, self._yarn] + args, cwd=cwd, extra_env=self.node_env(), shell=False
+ )
+ print('[LSP.api] START output of command: "{}"'.format(' '.join(args)))
+ print(stdout)
+ print('[LSP.api] Command output END')
+ if error is not None:
+ raise Exception('Failed to run yarn command "{}":\n{}'.format(' '.join(args), error))
+
+
+class ElectronInstaller:
+ '''Command to install a local copy of Node.js'''
+
+ def __init__(self, base_dir: str) -> None:
+ """
+ :param base_dir: The base directory for storing given Node.js runtime version
+ """
+ self._base_dir = base_dir
+ self._cache_dir = path.join(self._base_dir, 'cache')
+
+ def run(self) -> None:
+ archive, url = self._node_archive()
+ print(
+ '[LSP.api] Downloading Electron {} (Node.js runtime {}) from {}'.format(
+ ELECTRON_RUNTIME_VERSION, ELECTRON_NODE_VERSION, url
+ )
+ )
+ if not self._archive_exists(archive):
+ self._download(url, archive)
+ self._install(archive)
+ self._download_yarn()
+
+ def _node_archive(self) -> Tuple[str, str]:
+ platform = sublime.platform()
+ arch = sublime.arch()
+ if platform == 'windows':
+ platform_code = 'win32'
+ elif platform == 'linux':
+ platform_code = 'linux'
+ elif platform == 'osx':
+ platform_code = 'darwin'
+ else:
+ raise Exception('{} {} is not supported'.format(arch, platform))
+ filename = 'electron-v{}-{}-{}.zip'.format(ELECTRON_RUNTIME_VERSION, platform_code, arch)
+ dist_url = ELECTRON_DIST_URL.format(version=ELECTRON_RUNTIME_VERSION, filename=filename)
+ return filename, dist_url
+
+ def _archive_exists(self, filename: str) -> bool:
+ archive = path.join(self._cache_dir, filename)
+ return path.isfile(archive)
+
+ def _download(self, url: str, filename: str) -> None:
+ if not path.isdir(self._cache_dir):
+ os.makedirs(self._cache_dir)
+ archive = path.join(self._cache_dir, filename)
+ with urllib.request.urlopen(url) as response:
+ with open(archive, 'wb') as f:
+ shutil.copyfileobj(response, f)
+
+ def _install(self, filename: str) -> None:
+ archive = path.join(self._cache_dir, filename)
+ try:
+ if sublime.platform() == 'windows':
+ with zipfile.ZipFile(archive) as f:
+ names = f.namelist()
+ _, _ = next(x for x in names if '/' in x).split('/', 1)
+ bad_members = [x for x in names if x.startswith('/') or x.startswith('..')]
+ if bad_members:
+ raise Exception('{} appears to be malicious, bad filenames: {}'.format(filename, bad_members))
+ f.extractall(self._base_dir)
+ else:
+ # ZipFile doesn't handle symlinks and permissions correctly on Linux and Mac. Use unzip instead.
+ _, error = run_command_sync(['unzip', archive, '-d', self._base_dir], cwd=self._cache_dir)
+ if error:
+ raise Exception('Error unzipping electron archive: {}'.format(error))
+ except Exception as ex:
+ raise ex
+ finally:
+ remove(archive)
+
+ def _download_yarn(self) -> None:
+ archive = path.join(self._base_dir, 'yarn.js')
+ with urllib.request.urlopen(YARN_URL) as response:
+ with open(archive, 'wb') as f:
+ shutil.copyfileobj(response, f)
+
+
+@contextmanager
+def chdir(new_dir: str) -> Generator[None, None, None]:
+ '''Context Manager for changing the working directory'''
+ cur_dir = os.getcwd()
+ os.chdir(new_dir)
+ try:
+ yield
+ finally:
+ os.chdir(cur_dir)
diff --git a/api/npm_client_handler.py b/api/npm_client_handler.py
new file mode 100644
index 000000000..b9334bffa
--- /dev/null
+++ b/api/npm_client_handler.py
@@ -0,0 +1,168 @@
+from .generic_client_handler import GenericClientHandler
+from .server_npm_resource import ServerNpmResource
+from .server_resource_interface import ServerResourceInterface
+from LSP.plugin import ClientConfig
+from LSP.plugin import WorkspaceFolder
+from LSP.plugin.core.typing import Dict, List, Optional, Tuple
+from os import path
+import sublime
+
+__all__ = ['NpmClientHandler']
+
+
+class NpmClientHandler(GenericClientHandler):
+ """
+ An implementation of :class:`GenericClientHandler` for handling NPM-based LSP plugins.
+
+ Automatically manages an NPM-based server by installing and updating it in the package storage directory.
+ """
+ __server = None # type: Optional[ServerNpmResource]
+
+ server_directory = ''
+ """
+ The path to the server source directory, relative to the root directory of this package.
+
+ :required: Yes
+ """
+
+ server_binary_path = ''
+ """
+ The path to the server "binary", relative to plugin's storage directory.
+
+ :required: Yes
+ """
+
+ skip_npm_install = False
+ """
+ Whether to skip the step that runs "npm install" in case the server doesn't need any dependencies.
+
+ :required: No
+ """
+
+ # --- NpmClientHandler handlers -----------------------------------------------------------------------------------
+
+ @classmethod
+ def minimum_node_version(cls) -> Tuple[int, int, int]:
+ """
+ .. deprecated:: 2.1.0
+ Use :meth:`required_node_version` instead.
+
+ The minimum Node version required for this plugin.
+
+ :returns: The semantic version tuple with the minimum required version. Defaults to :code:`(8, 0, 0)`.
+ """
+ return (8, 0, 0)
+
+ @classmethod
+ def required_node_version(cls) -> str:
+ """
+ The NPM semantic version (typically a range) specifying which version of Node is required for this plugin.
+
+ Examples:
+ - `16.1.1` - only allows a single version
+ - `16.x` - allows any build for major version 16
+ - `>=16` - allows version 16 and above
+ - `16 - 18` allows any version between version 16 and 18 (inclusive). It's important to have spaces around
+ the dash in this case.
+
+ Also see more examples and a testing playground at https://semver.npmjs.com/ .
+
+ :returns: Required NPM semantic version. Defaults to :code:`0.0.0` which means "no restrictions".
+ """
+ return '0.0.0'
+
+ @classmethod
+ def get_additional_variables(cls) -> Dict[str, str]:
+ """
+ Overrides :meth:`GenericClientHandler.get_additional_variables`, providing additional variable for use in the
+ settings.
+
+ The additional variables are:
+
+ - `${node_bin}`: - holds the binary path of currently used Node.js runtime. This can resolve to just `node`
+ when using Node.js runtime from the PATH or to a full filesystem path if using the local Node.js runtime.
+ - `${server_directory_path}` - holds filesystem path to the server directory (only
+ when :meth:`GenericClientHandler.manages_server()` is `True`).
+
+ Remember to call the super class and merge the results if overriding.
+ """
+ variables = super().get_additional_variables()
+ variables.update({
+ 'node_bin': cls._node_bin(),
+ 'server_directory_path': cls._server_directory_path(),
+ })
+ return variables
+
+ @classmethod
+ def get_additional_paths(cls) -> List[str]:
+ node_bin = cls._node_bin()
+ if node_bin:
+ node_path = path.dirname(node_bin)
+ if node_path:
+ return [node_path]
+ return []
+
+ # --- GenericClientHandler handlers -------------------------------------------------------------------------------
+
+ @classmethod
+ def get_command(cls) -> List[str]:
+ return [cls._node_bin(), cls.binary_path()] + cls.get_binary_arguments()
+
+ @classmethod
+ def get_binary_arguments(cls) -> List[str]:
+ return ['--stdio']
+
+ @classmethod
+ def manages_server(cls) -> bool:
+ return True
+
+ @classmethod
+ def get_server(cls) -> Optional[ServerResourceInterface]:
+ if not cls.__server:
+ cls.__server = ServerNpmResource.create({
+ 'package_name': cls.package_name,
+ 'server_directory': cls.server_directory,
+ 'server_binary_path': cls.server_binary_path,
+ 'package_storage': cls.package_storage(),
+ 'minimum_node_version': cls.minimum_node_version(),
+ 'required_node_version': cls.required_node_version(),
+ 'storage_path': cls.storage_path(),
+ 'skip_npm_install': cls.skip_npm_install,
+ })
+ return cls.__server
+
+ @classmethod
+ def cleanup(cls) -> None:
+ cls.__server = None
+ super().cleanup()
+
+ @classmethod
+ def can_start(cls, window: sublime.Window, initiating_view: sublime.View,
+ workspace_folders: List[WorkspaceFolder], configuration: ClientConfig) -> Optional[str]:
+ reason = super().can_start(window, initiating_view, workspace_folders, configuration)
+ if reason:
+ return reason
+ node_env = cls._node_env()
+ if node_env:
+ configuration.env.update(node_env)
+ return None
+
+ # --- Internal ----------------------------------------------------------------------------------------------------
+
+ @classmethod
+ def _server_directory_path(cls) -> str:
+ if cls.__server:
+ return cls.__server.server_directory_path
+ return ''
+
+ @classmethod
+ def _node_bin(cls) -> str:
+ if cls.__server:
+ return cls.__server.node_bin
+ return ''
+
+ @classmethod
+ def _node_env(cls) -> Optional[Dict[str, str]]:
+ if cls.__server:
+ return cls.__server.node_env
+ return None
diff --git a/api/pip_client_handler.py b/api/pip_client_handler.py
new file mode 100644
index 000000000..391037eb8
--- /dev/null
+++ b/api/pip_client_handler.py
@@ -0,0 +1,77 @@
+from .generic_client_handler import GenericClientHandler
+from .server_pip_resource import ServerPipResource
+from .server_resource_interface import ServerResourceInterface
+from LSP.plugin.core.typing import List, Optional
+from os import path
+import shutil
+import sublime
+
+__all__ = ['PipClientHandler']
+
+
+class PipClientHandler(GenericClientHandler):
+ """
+ An implementation of :class:`GenericClientHandler` for handling pip-based LSP plugins.
+
+ Automatically manages a pip-based server by installing and updating dependencies based on provided
+ `requirements.txt` file.
+ """
+ __server = None # type: Optional[ServerPipResource]
+
+ requirements_txt_path = ''
+ """
+ The path to the `requirements.txt` file containing a list of dependencies required by the server.
+
+ If the package `LSP-foo` has a `requirements.txt` file at the root then the path will be just `requirements.txt`.
+
+ The file format is `dependency_name==dependency_version` or just a direct path to the dependency (for example to
+ a github repo). For example:
+
+ .. code::
+
+ pyls==0.1.2
+ colorama==1.2.2
+ git+https://github.com/tomv564/pyls-mypy.git
+
+ :required: Yes
+ """
+
+ server_filename = ''
+ """
+ The file name of the binary used to start the server.
+
+ :required: Yes
+ """
+
+ @classmethod
+ def get_python_binary(cls) -> str:
+ """
+ Returns a binary name or a full path to the Python interpreter used to create environment for the server.
+
+ The default implementation returns `python` on Windows and `python3` on other platforms. When only the binary
+ name is specified then it will be expected that it can be found on the PATH.
+ """
+ return 'python' if sublime.platform() == 'windows' else 'python3'
+
+ # --- GenericClientHandler handlers -------------------------------------------------------------------------------
+
+ @classmethod
+ def manages_server(cls) -> bool:
+ return True
+
+ @classmethod
+ def get_server(cls) -> Optional[ServerResourceInterface]:
+ if not cls.__server:
+ python_binary = cls.get_python_binary()
+ if not shutil.which(python_binary):
+ raise Exception('Python binary "{}" not found!'.format(python_binary))
+ cls.__server = ServerPipResource(
+ cls.storage_path(), cls.package_name, cls.requirements_txt_path, cls.server_filename, python_binary)
+ return cls.__server
+
+ @classmethod
+ def get_additional_paths(cls) -> List[str]:
+ server = cls.get_server()
+ if server:
+ return [path.dirname(server.binary_path)]
+ return []
diff --git a/api/server_npm_resource.py b/api/server_npm_resource.py
new file mode 100644
index 000000000..ecacbde59
--- /dev/null
+++ b/api/server_npm_resource.py
@@ -0,0 +1,140 @@
+from .helpers import rmtree_ex
+from .helpers import SemanticVersion
+from .node_runtime import NodeRuntime
+from .server_resource_interface import ServerResourceInterface
+from .server_resource_interface import ServerStatus
+from hashlib import md5
+from LSP.plugin.core.typing import Dict, Optional, TypedDict, Union
+from os import makedirs
+from os import path
+from os import remove
+from os import walk
+from sublime_lib import ResourcePath
+
+__all__ = ['ServerNpmResource']
+
+ServerNpmResourceCreateOptions = TypedDict('ServerNpmResourceCreateOptions', {
+ 'package_name': str,
+ 'server_directory': str,
+ 'server_binary_path': str,
+ 'package_storage': str,
+ 'storage_path': str,
+ 'minimum_node_version': SemanticVersion,
+ 'required_node_version': str,
+ 'skip_npm_install': bool,
+})
+
+
+class ServerNpmResource(ServerResourceInterface):
+ """
+ An implementation of :class:`LSP.api.ServerResourceInterface` implementing server management for
+ node-based severs. Handles installation and updates of the server in package storage.
+ """
+
+ @classmethod
+ def create(cls, options: ServerNpmResourceCreateOptions) -> 'ServerNpmResource':
+ package_name = options['package_name']
+ server_directory = options['server_directory']
+ server_binary_path = options['server_binary_path']
+ package_storage = options['package_storage']
+ storage_path = options['storage_path']
+ minimum_node_version = options['minimum_node_version']
+ required_node_version = options['required_node_version'] # type: Union[str, SemanticVersion]
+ skip_npm_install = options['skip_npm_install']
+ # Fallback to "minimum_node_version" if "required_node_version" is 0.0.0 (not overridden).
+ if '0.0.0' == required_node_version:
+ required_node_version = minimum_node_version
+ node_runtime = NodeRuntime.get(package_name, storage_path, required_node_version)
+ if not node_runtime:
+ raise Exception('Failed resolving Node.js Runtime. Please see Sublime Text console for more information.')
+ return ServerNpmResource(
+ package_name, server_directory, server_binary_path, package_storage, node_runtime, skip_npm_install)
+
+ def __init__(self, package_name: str, server_directory: str, server_binary_path: str,
+ package_storage: str, node_runtime: NodeRuntime, skip_npm_install: bool) -> None:
+ if not package_name or not server_directory or not server_binary_path or not node_runtime:
+ raise Exception('ServerNpmResource could not initialize due to wrong input')
+ self._status = ServerStatus.UNINITIALIZED
+ self._package_name = package_name
+ self._package_storage = package_storage
+ self._server_src = 'Packages/{}/{}/'.format(self._package_name, server_directory)
+ node_version = str(node_runtime.resolve_version())
+ self._node_version = node_version
+ self._server_dest = path.join(package_storage, node_version, server_directory)
+ self._binary_path = path.join(package_storage, node_version, server_binary_path)
+ self._installation_marker_file = path.join(package_storage, node_version, '.installing')
+ self._node_runtime = node_runtime
+ self._skip_npm_install = skip_npm_install
+
+ @property
+ def server_directory_path(self) -> str:
+ return self._server_dest
+
+ @property
+ def node_bin(self) -> str:
+ node_bin = self._node_runtime.node_bin()
+ if node_bin is None:
+ raise Exception('Failed to resolve path to the Node.js runtime')
+ return node_bin
+
+ @property
+ def node_env(self) -> Optional[Dict[str, str]]:
+ return self._node_runtime.node_env()
+
+ # --- ServerResourceInterface -------------------------------------------------------------------------------------
+
+ @property
+ def binary_path(self) -> str:
+ return self._binary_path
+
+ def get_status(self) -> int:
+ return self._status
+
+ def needs_installation(self) -> bool:
+ installed = False
+ if self._skip_npm_install or path.isdir(path.join(self._server_dest, 'node_modules')):
+ # Server already installed. Check if version has changed or last installation did not complete.
+ src_package_json = ResourcePath(self._server_src, 'package.json')
+ if not src_package_json.exists():
+ raise Exception('Missing required "package.json" in {}'.format(self._server_src))
+ src_hash = md5(src_package_json.read_bytes()).hexdigest()
+ try:
+ with open(path.join(self._server_dest, 'package.json'), 'rb') as file:
+ dst_hash = md5(file.read()).hexdigest()
+ if src_hash == dst_hash and not path.isfile(self._installation_marker_file):
+ installed = True
+ except FileNotFoundError:
+ # Needs to be re-installed.
+ pass
+ if installed:
+ self._status = ServerStatus.READY
+ return False
+ return True
+
+ def install_or_update(self) -> None:
+ try:
+ self._cleanup_package_storage()
+ makedirs(path.dirname(self._installation_marker_file), exist_ok=True)
+ open(self._installation_marker_file, 'a').close()
+ if path.isdir(self._server_dest):
+ rmtree_ex(self._server_dest)
+ ResourcePath(self._server_src).copytree(self._server_dest, exist_ok=True)
+ if not self._skip_npm_install:
+ self._node_runtime.run_install(cwd=self._server_dest)
+ remove(self._installation_marker_file)
+ except Exception as error:
+ self._status = ServerStatus.ERROR
+ raise Exception('Error installing the server:\n{}'.format(error))
+ self._status = ServerStatus.READY
+
+ def _cleanup_package_storage(self) -> None:
+ if not path.isdir(self._package_storage):
+ return
+ """Clean up subdirectories of package storage that belong to other node versions."""
+ subdirectories = next(walk(self._package_storage))[1]
+ for directory in subdirectories:
+ if directory == self._node_version:
+ continue
+ node_storage_path = path.join(self._package_storage, directory)
+ print('[LSP.api] Deleting outdated storage directory "{}"'.format(node_storage_path))
+ rmtree_ex(node_storage_path)
diff --git a/api/server_pip_resource.py b/api/server_pip_resource.py
new file mode 100644
index 000000000..4d941f7fe
--- /dev/null
+++ b/api/server_pip_resource.py
@@ -0,0 +1,109 @@
+from .helpers import rmtree_ex
+from .helpers import run_command_sync
+from .server_resource_interface import ServerResourceInterface
+from .server_resource_interface import ServerStatus
+from hashlib import md5
+from LSP.plugin.core.typing import Any, Optional
+from os import path
+from sublime_lib import ResourcePath
+import os
+import sublime
+
+__all__ = ['ServerPipResource']
+
+
+class ServerPipResource(ServerResourceInterface):
+ """
+ An implementation of :class:`LSP.api.ServerResourceInterface` implementing server management for
+ pip-based servers. Handles installation and updates of the server in the package storage.
+
+ :param storage_path: The path to the package storage (pass :meth:`LSP.api.GenericClientHandler.storage_path()`)
+ :param package_name: The package name (used as a directory name for storage)
+ :param requirements_path: The path to the `requirements.txt` file, relative to the package directory.
+ If the package `LSP-foo` has a `requirements.txt` file at the root then the path will be `requirements.txt`.
+ :param server_binary_filename: The name of the file used to start the server.
+ """
+
+ @classmethod
+ def file_extension(cls) -> str:
+ return '.exe' if sublime.platform() == 'windows' else ''
+
+ @classmethod
+ def run(cls, *args: Any, cwd: Optional[str] = None) -> str:
+ output, error = run_command_sync(list(args), cwd=cwd)
+ if error:
+ raise Exception(error)
+ return output
+
+ def __init__(self, storage_path: str, package_name: str, requirements_path: str,
+ server_binary_filename: str, python_binary: str) -> None:
+ self._storage_path = storage_path
+ self._package_name = package_name
+ self._requirements_path_relative = requirements_path
+ self._requirements_path = 'Packages/{}/{}'.format(self._package_name, requirements_path)
+ self._server_binary_filename = server_binary_filename
+ self._python_binary = python_binary
+ self._status = ServerStatus.UNINITIALIZED
+
+ def basedir(self) -> str:
+ return path.join(self._storage_path, self._package_name)
+
+ def bindir(self) -> str:
+ bin_dir = 'Scripts' if sublime.platform() == 'windows' else 'bin'
+ return path.join(self.basedir(), bin_dir)
+
+ def server_binary(self) -> str:
+ return path.join(self.bindir(), self._server_binary_filename + self.file_extension())
+
+ def pip_binary(self) -> str:
+ return path.join(self.bindir(), 'pip' + self.file_extension())
+
+ def python_version(self) -> str:
+ return path.join(self.basedir(), 'python_version')
+
+ # --- ServerResourceInterface handlers ----------------------------------------------------------------------------
+
+ @property
+ def binary_path(self) -> str:
+ return self.server_binary()
+
+ def get_status(self) -> int:
+ return self._status
+
+ def needs_installation(self) -> bool:
+ if not path.exists(self.server_binary()) or not path.exists(self.pip_binary()):
+ return True
+ if not path.exists(self.python_version()):
+ return True
+ with open(self.python_version(), 'r') as f:
+ if f.readline().strip() != self.run(self._python_binary, '--version').strip():
+ return True
+ src_requirements_resource = ResourcePath(self._requirements_path)
+ if not src_requirements_resource.exists():
+ raise Exception('Missing required "requirements.txt" in {}'.format(self._requirements_path))
+ src_requirements_hash = md5(src_requirements_resource.read_bytes()).hexdigest()
+ try:
+ with open(path.join(self.basedir(), self._requirements_path_relative), 'rb') as file:
+ dst_requirements_hash = md5(file.read()).hexdigest()
+ if src_requirements_hash != dst_requirements_hash:
+ return True
+ except FileNotFoundError:
+ # Needs to be re-installed.
+ return True
+ self._status = ServerStatus.READY
+ return False
+
+ def install_or_update(self) -> None:
+ rmtree_ex(self.basedir(), ignore_errors=True)
+ try:
+ os.makedirs(self.basedir(), exist_ok=True)
+ self.run(self._python_binary, '-m', 'venv', self._package_name, cwd=self._storage_path)
+ dest_requirements_txt_path = path.join(self._storage_path, self._package_name, 'requirements.txt')
+ ResourcePath(self._requirements_path).copy(dest_requirements_txt_path)
+ self.run(self.pip_binary(), 'install', '-r', dest_requirements_txt_path, '--disable-pip-version-check')
+ with open(self.python_version(), 'w') as f:
+ f.write(self.run(self._python_binary, '--version'))
+ except Exception as error:
+ self._status = ServerStatus.ERROR
+ raise Exception('Error installing the server:\n{}'.format(error))
+ self._status = ServerStatus.READY
diff --git a/api/server_resource_interface.py b/api/server_resource_interface.py
new file mode 100644
index 000000000..489907135
--- /dev/null
+++ b/api/server_resource_interface.py
@@ -0,0 +1,66 @@
+from abc import ABCMeta
+from abc import abstractmethod
+from abc import abstractproperty
+
+__all__ = ['ServerStatus', 'ServerResourceInterface']
+
+
+class ServerStatus():
+ """
+ A :class:`ServerStatus` enum for use as a return value from :func:`ServerResourceInterface.get_status()`.
+ """
+
+ UNINITIALIZED = 1
+ """Initial status of the server."""
+ ERROR = 2
+ """Initiallation or update has failed."""
+ READY = 3
+ """Server is ready to provide resources."""
+
+
+class ServerResourceInterface(metaclass=ABCMeta):
+ """
+ An interface for implementating server resource handlers. Use this interface in plugins that manage their own
+ server binary (:func:`GenericClientHandler.manages_server` returns `True`).
+
+ After implementing this interface, return an instance of implemented class from
+ :meth:`GenericClientHandler.get_server()`.
+ """
+
+ @abstractmethod
+ def needs_installation(self) -> bool:
+ """
+ This is the place to check whether the binary needs an update, or whether it needs to be installed before
+ starting the language server.
+
+ :returns: `True` if the server needs to be installed or updated. This will result in calling
+ :meth:`install_or_update()`.
+ """
+ ...
+
+ @abstractmethod
+ def install_or_update(self) -> None:
+ """
+ Do the actual update/installation of the server binary. Don't start extra threads to do the work as everything
+ is handled automatically.
+ """
+ ...
+
+ @abstractmethod
+ def get_status(self) -> int:
+ """
+ Determines the current status of the server. The state changes as the server is being installed, updated or
+ runs into an error doing those. Initialize with :attr:`ServerStatus.UNINITIALIZED` and change to either
+ Set to :attr:`ServerStatus.ERROR` or :attr:`ServerStatus.READY` depending on if the server was installed
+ correctly or is already installed.
+
+ :returns: A number corresponding to the :class:`ServerStatus` class members.
+ """
+ ...
+
+ @abstractproperty
+ def binary_path(self) -> str:
+ """
+ Returns a filesystem path to the server binary.
+ """
+ ...
diff --git a/dependencies.json b/dependencies.json
index b7f778153..7f35e31f2 100644
--- a/dependencies.json
+++ b/dependencies.json
@@ -4,6 +4,7 @@
"bracex",
"mdpopups",
"pathlib",
+ "sublime_lib",
"wcmatch"
]
}
diff --git a/docs/api/api_handler.rst b/docs/api/api_handler.rst
new file mode 100644
index 000000000..a8e071104
--- /dev/null
+++ b/docs/api/api_handler.rst
@@ -0,0 +1,30 @@
+API Handler
+===========
+
+ApiWrapperInterface
+-------------------
+
+.. autoclass:: LSP.api.ApiWrapperInterface
+
+API Decorators
+--------------
+
+Decorators can be used as an alternative to attaching listeners to the :class:`LSP.api.ApiWrapperInterface` instance obtained through :meth:`LSP.api.GenericClientHandler.on_ready()`.
+
+To use, attach the decorator to a function and call it with the name of the notification or the request that you want to handle. It can also be called with a list of names, if you want to use the same handler to handle multiple requests.
+
+Example usage:
+
+.. code:: py
+
+ @notification_handler('eslint/status')
+ def handle_status(self, params: Any) -> None:
+ print(status)
+
+ @request_handler('eslint/openDoc')
+ def handle_open_doc(self, params: Any, respond: Callable[[Any], None]) -> None:
+ webbrowser.open(params['url'])
+ respond({})
+
+.. autoclass:: LSP.api.request_handler
+.. autoclass:: LSP.api.notification_handler
diff --git a/docs/api/client_handlers.rst b/docs/api/client_handlers.rst
new file mode 100644
index 000000000..cf34b493f
--- /dev/null
+++ b/docs/api/client_handlers.rst
@@ -0,0 +1,17 @@
+Client Handlers
+===============
+
+GenericClientHandler
+--------------------
+
+.. autoclass:: LSP.api.GenericClientHandler
+
+NpmClientHandler
+----------------
+
+.. autoclass:: LSP.api.NpmClientHandler
+
+PipClientHandler
+----------------
+
+.. autoclass:: LSP.api.pip_client_handler.PipClientHandler
diff --git a/docs/api/conf.py b/docs/api/conf.py
new file mode 100644
index 000000000..1b84a6ed6
--- /dev/null
+++ b/docs/api/conf.py
@@ -0,0 +1,135 @@
+# -*- coding: utf-8 -*-
+#
+# Configuration file for the Sphinx documentation builder.
+#
+# This file does only contain a selection of the most common options. For a
+# full list see the documentation:
+# http://www.sphinx-doc.org/en/master/config
+
+# -- Path setup --------------------------------------------------------------
+
+# If extensions (or modules to document with autodoc) are in another directory,
+# add these directories to sys.path here. If the directory is relative to the
+# documentation root, use os.path.abspath to make it absolute, like shown here.
+#
+import os
+import sys
+sys.path.insert(0, os.path.abspath('../../..'))
+
+import sphinx_rtd_theme
+
+# -- Project information -----------------------------------------------------
+
+project = 'LSP.api'
+copyright = '2022, SublimeLSP'
+author = 'Rafal Chlodnicki'
+
+# The short X.Y version
+version = ''
+# The full version, including alpha/beta/rc tags
+release = ''
+
+
+# -- General configuration ---------------------------------------------------
+
+# If your documentation needs a minimal Sphinx version, state it here.
+#
+# needs_sphinx = '1.0'
+
+# Add any Sphinx extension module names here, as strings. They can be
+# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
+# ones.
+extensions = [
+ 'sphinx_rtd_theme',
+ 'sphinx.ext.autodoc',
+ 'sphinx_autodoc_typehints',
+ 'sphinx.ext.intersphinx',
+]
+
+always_document_param_types = True
+
+autodoc_inherit_docstrings = False
+autodoc_member_order = 'bysource'
+autodoc_mock_imports = [
+ 'package_control', 'sublime', 'sublime_api', 'sublime_lib', 'sublime_plugin', 'markdown', 'mdpopups', 'wcmatch'
+]
+autodoc_default_options = {
+ 'members': True,
+ 'inherited-members': False,
+ # 'show-inheritance': True,
+ # 'mock_imports': ['sublime_plugin']
+}
+
+intersphinx_mapping = {'python': ('https://docs.python.org/3', None)}
+
+# Add any paths that contain templates here, relative to this directory.
+templates_path = ['_templates']
+
+# The suffix(es) of source filenames.
+# You can specify multiple suffix as a list of string:
+#
+# source_suffix = ['.rst', '.md']
+source_suffix = ['.rst']
+
+# The master toctree document.
+master_doc = 'index'
+
+# The language for content autogenerated by Sphinx. Refer to documentation
+# for a list of supported languages.
+#
+# This is also used if you do content translation via gettext catalogs.
+# Usually you set "language" from the command line for these cases.
+language = 'en'
+
+# List of patterns, relative to source directory, that match files and
+# directories to ignore when looking for source files.
+# This pattern also affects html_static_path and html_extra_path .
+exclude_patterns = []
+
+# The name of the Pygments (syntax highlighting) style to use.
+pygments_style = 'sphinx'
+
+
+# -- Options for HTML output -------------------------------------------------
+
+html_context = {
+ 'display_github': True,
+ 'github_user': 'sublimelsp',
+ 'github_repo': 'lsp_utils',
+ 'github_version': 'master/docs/source/',
+}
+
+# The theme to use for HTML and HTML Help pages. See the documentation for
+# a list of builtin themes.
+#
+html_theme = 'sphinx_rtd_theme'
+
+# Theme options are theme-specific and customize the look and feel of a theme
+# further. For a list of options available for each theme, see the
+# documentation.
+#
+html_theme_options = {
+ # sphinx_rtd_theme
+ 'collapse_navigation': False,
+}
+
+html_experimental_html5_writer = True
+
+# Add any paths that contain custom static files (such as style sheets) here,
+# relative to this directory. They are copied after the builtin static files,
+# so a file named "default.css" will overwrite the builtin "default.css".
+# html_static_path = ['_static']
+
+# Custom sidebar templates, must be a dictionary that maps document names
+# to template names.
+#
+# The default sidebars (for documents that don't match any pattern) are
+# defined by theme itself. Builtin themes are using these templates by
+# default: ``['localtoc.html', 'relations.html', 'sourcelink.html',
+# 'searchbox.html']``.
+#
+# html_sidebars = {}
+
+html_use_index = False
+html_use_smartypants = False
+html_compact_lists = True
diff --git a/docs/api/index.rst b/docs/api/index.rst
new file mode 100644
index 000000000..8dba2e2fa
--- /dev/null
+++ b/docs/api/index.rst
@@ -0,0 +1,35 @@
+lsp_utils
+=========
+
+Module with LSP-related utilities for Sublime Text
+
+How to use
+----------
+
+1. Create a `dependencies.json` file in your package root with the following contents:
+
+.. code:: py
+
+ {
+ "*": {
+ "*": [
+ "lsp_utils",
+ "sublime_lib"
+ ]
+ }
+ }
+
+2. Run the **Package Control: Satisfy Dependencies** command via command palette
+
+
+See also documentation on dependencies_.
+
+.. _dependencies: https://packagecontrol.io/docs/dependencies
+
+.. toctree::
+ :caption: API Documentation
+
+ client_handlers
+ server_resource_handlers
+ api_handler
+ utilities
diff --git a/docs/api/server_resource_handlers.rst b/docs/api/server_resource_handlers.rst
new file mode 100644
index 000000000..4c21a0846
--- /dev/null
+++ b/docs/api/server_resource_handlers.rst
@@ -0,0 +1,17 @@
+Server Resource Handlers
+========================
+
+ServerStatus
+------------
+
+.. autoclass:: lsp_utils.ServerStatus
+
+ServerResourceInterface
+-----------------------
+
+.. autoclass:: lsp_utils.ServerResourceInterface
+
+ServerPipResource
+-----------------
+
+.. autoclass:: lsp_utils.ServerPipResource
diff --git a/docs/api/utilities.rst b/docs/api/utilities.rst
new file mode 100644
index 000000000..6dfd642a2
--- /dev/null
+++ b/docs/api/utilities.rst
@@ -0,0 +1,4 @@
+Utilities
+---------
+
+.. automodule:: lsp_utils.helpers
diff --git a/plugin/core/constants.py b/plugin/core/constants.py
index 8d249e4aa..8c1b97ff2 100644
--- a/plugin/core/constants.py
+++ b/plugin/core/constants.py
@@ -15,6 +15,8 @@
# Keys for View.add_regions
HOVER_HIGHLIGHT_KEY = 'lsp_hover_highlight'
+# Settings
+SUBLIME_SETTINGS_FILENAME = 'LSP.sublime-settings'
# Setting keys
CODE_LENS_ENABLED_KEY = 'lsp_show_code_lens'
HOVER_ENABLED_KEY = 'lsp_show_hover_popups'
diff --git a/plugin/core/settings.py b/plugin/core/settings.py
index 319de6e55..c2a94afc7 100644
--- a/plugin/core/settings.py
+++ b/plugin/core/settings.py
@@ -1,4 +1,5 @@
from .collections import DottedDict
+from .constants import SUBLIME_SETTINGS_FILENAME
from .logging import debug
from .types import ClientConfig, debounced
from .types import read_dict_setting
@@ -89,13 +90,13 @@ def _set_enabled(self, config_name: str, is_enabled: bool) -> None:
plugin_settings.set("enabled", is_enabled)
sublime.save_settings(settings_basename)
return
- settings = sublime.load_settings("LSP.sublime-settings")
+ settings = sublime.load_settings(SUBLIME_SETTINGS_FILENAME)
clients = settings.get("clients")
if isinstance(clients, dict):
config = clients.setdefault(config_name, {})
config["enabled"] = is_enabled
settings.set("clients", clients)
- sublime.save_settings("LSP.sublime-settings")
+ sublime.save_settings(SUBLIME_SETTINGS_FILENAME)
def enable(self, config_name: str) -> None:
self._set_enabled(config_name, True)
@@ -129,7 +130,7 @@ def load_settings() -> None:
if _global_settings is None:
_global_settings = sublime.load_settings("Preferences.sublime-settings")
if _settings_obj is None:
- _settings_obj = sublime.load_settings("LSP.sublime-settings")
+ _settings_obj = sublime.load_settings(SUBLIME_SETTINGS_FILENAME)
_settings = Settings(_settings_obj)
_settings_registration = SettingsRegistration(_settings_obj, _on_sublime_settings_changed)
diff --git a/plugin/core/types.py b/plugin/core/types.py
index 7fac717c2..f2b37cc92 100644
--- a/plugin/core/types.py
+++ b/plugin/core/types.py
@@ -1,4 +1,5 @@
from .collections import DottedDict
+from .constants import SUBLIME_SETTINGS_FILENAME
from .file_watcher import FileWatcherEventType
from .logging import debug, set_debug_logging
from .protocol import TextDocumentSyncKind
@@ -827,7 +828,7 @@ def resolve_transport_config(self, variables: Dict[str, str]) -> TransportConfig
return TransportConfig(self.name, command, tcp_port, env, listener_socket)
def set_view_status(self, view: sublime.View, message: str) -> None:
- if sublime.load_settings("LSP.sublime-settings").get("show_view_status"):
+ if sublime.load_settings(SUBLIME_SETTINGS_FILENAME).get("show_view_status"):
status = "{} ({})".format(self.name, message) if message else self.name
view.set_status(self.status_key, status)
diff --git a/plugin/tooling.py b/plugin/tooling.py
index 71592ad57..51ea315c1 100644
--- a/plugin/tooling.py
+++ b/plugin/tooling.py
@@ -1,3 +1,4 @@
+from .core.constants import SUBLIME_SETTINGS_FILENAME
from .core.css import css
from .core.logging import debug
from .core.registry import windows
@@ -394,7 +395,7 @@ def line(s: str) -> None:
line(' - project data:\n{}'.format(self.json_dump(window.project_data())))
line('\n## LSP configuration\n')
- lsp_settings_contents = self.read_resource('Packages/User/LSP.sublime-settings')
+ lsp_settings_contents = self.read_resource('Packages/User/{}'.format(SUBLIME_SETTINGS_FILENAME))
if lsp_settings_contents is not None:
line(self.json_dump(sublime.decode_value(lsp_settings_contents)))
else:
diff --git a/sublime-package.json b/sublime-package.json
index 17a0f17ee..eb9efd581 100644
--- a/sublime-package.json
+++ b/sublime-package.json
@@ -676,6 +676,31 @@
"minItems": 0,
"deprecationMessage": "Instead of a global option, this option is now on a per-client basis. Moreover, this is now an object instead of an array."
},
+ "nodejs_runtime": {
+ "type": "array",
+ "markdownDescription": "Specifies the type and priority of the Node.js installation that should be used for Node.js-based servers.\n\nThe allowed values are:\n\n- `system` - a Node.js runtime found on the PATH\n- `local` - a Node.js runtime managed by LSP that doesn't affect the system\n\nThe order in which the values are specified determines which one is tried first,\nwith the later one being used as a fallback.\nYou can also specify just a single value to disable the fallback.",
+ "default": [
+ "system",
+ "local",
+ ],
+ "items": {
+ "type": "string",
+ "enum": [
+ "system",
+ "local"
+ ],
+ "markdownEnumDescriptions": [
+ "Node.js runtime found on the PATH",
+ "Node.js runtime managed by LSP"
+ ]
+ },
+ "uniqueItems": true,
+ },
+ "local_use_electron": {
+ "type": "boolean",
+ "default": false,
+ "markdownDescription": "Uses Node.js runtime from the Electron package rather than the official distribution. This has the benefit of lower memory usage due to it having the [pointer compression](https://v8.dev/blog/pointer-compression) enabled.\n\nOnly relevant when using `local` variant of `nodejs_runtime`."
+ },
"log_debug": {
"type": "boolean",
"default": false,
diff --git a/third_party/semantic_version/LICENSE b/third_party/semantic_version/LICENSE
new file mode 100644
index 000000000..66aba18ca
--- /dev/null
+++ b/third_party/semantic_version/LICENSE
@@ -0,0 +1,22 @@
+Copyright (c) The python-semanticversion project
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/third_party/semantic_version/README.rst b/third_party/semantic_version/README.rst
new file mode 100644
index 000000000..c50163a4c
--- /dev/null
+++ b/third_party/semantic_version/README.rst
@@ -0,0 +1,279 @@
+Introduction
+============
+
+This small python library provides a few tools to handle `SemVer`_ in Python.
+It follows strictly the 2.0.0 version of the SemVer scheme.
+
+.. image:: https://github.com/rbarrois/python-semanticversion/actions/workflows/test.yml/badge.svg
+ :target: https://github.com/rbarrois/python-semanticversion/actions/workflows/test.yml
+
+.. image:: https://img.shields.io/pypi/v/semantic_version.svg
+ :target: https://python-semanticversion.readthedocs.io/en/latest/changelog.html
+ :alt: Latest Version
+
+.. image:: https://img.shields.io/pypi/pyversions/semantic_version.svg
+ :target: https://pypi.python.org/pypi/semantic_version/
+ :alt: Supported Python versions
+
+.. image:: https://img.shields.io/pypi/wheel/semantic_version.svg
+ :target: https://pypi.python.org/pypi/semantic_version/
+ :alt: Wheel status
+
+.. image:: https://img.shields.io/pypi/l/semantic_version.svg
+ :target: https://pypi.python.org/pypi/semantic_version/
+ :alt: License
+
+Links
+-----
+
+- Package on `PyPI`_: https://pypi.org/project/semantic-version/
+- Doc on `ReadTheDocs `_: https://python-semanticversion.readthedocs.io/
+- Source on `GitHub `_: http://github.com/rbarrois/python-semanticversion/
+- Build on Github Actions: https://github.com/rbarrois/python-semanticversion/actions
+- Semantic Version specification: `SemVer`_
+
+
+Getting started
+===============
+
+Install the package from `PyPI`_, using pip:
+
+.. code-block:: sh
+
+ pip install semantic-version
+
+Or from GitHub:
+
+.. code-block:: sh
+
+ $ git clone git://github.com/rbarrois/python-semanticversion.git
+
+
+Import it in your code:
+
+
+.. code-block:: python
+
+ import semantic_version
+
+
+This module provides classes to handle semantic versions:
+
+- ``Version`` represents a version number (``0.1.1-alpha+build.2012-05-15``)
+- ``BaseSpec``-derived classes represent requirement specifications (``>=0.1.1,<0.3.0``):
+
+ - ``SimpleSpec`` describes a natural description syntax
+ - ``NpmSpec`` is used for NPM-style range descriptions.
+
+Versions
+--------
+
+Defining a ``Version`` is quite simple:
+
+
+.. code-block:: pycon
+
+ >>> import semantic_version
+ >>> v = semantic_version.Version('0.1.1')
+ >>> v.major
+ 0
+ >>> v.minor
+ 1
+ >>> v.patch
+ 1
+ >>> v.prerelease
+ []
+ >>> v.build
+ []
+ >>> list(v)
+ [0, 1, 1, [], []]
+
+If the provided version string is invalid, a ``ValueError`` will be raised:
+
+.. code-block:: pycon
+
+ >>> semantic_version.Version('0.1')
+ Traceback (most recent call last):
+ File "", line 1, in
+ File "/Users/rbarrois/dev/semantic_version/src/semantic_version/base.py", line 64, in __init__
+ major, minor, patch, prerelease, build = self.parse(version_string, partial)
+ File "/Users/rbarrois/dev/semantic_version/src/semantic_version/base.py", line 86, in parse
+ raise ValueError('Invalid version string: %r' % version_string)
+ ValueError: Invalid version string: '0.1'
+
+
+One may also create a ``Version`` with named components:
+
+.. code-block:: pycon
+
+ >>> semantic_version.Version(major=0, minor=1, patch=2)
+ Version('0.1.2')
+
+In that case, ``major``, ``minor`` and ``patch`` are mandatory, and must be integers.
+``prerelease`` and ``build``, if provided, must be tuples of strings:
+
+.. code-block:: pycon
+
+ >>> semantic_version.Version(major=0, minor=1, patch=2, prerelease=('alpha', '2'))
+ Version('0.1.2-alpha.2')
+
+
+Some user-supplied input might not match the semantic version scheme.
+For such cases, the ``Version.coerce`` method will try to convert any
+version-like string into a valid semver version:
+
+.. code-block:: pycon
+
+ >>> Version.coerce('0')
+ Version('0.0.0')
+ >>> Version.coerce('0.1.2.3.4')
+ Version('0.1.2+3.4')
+ >>> Version.coerce('0.1.2a3')
+ Version('0.1.2-a3')
+
+Working with versions
+"""""""""""""""""""""
+
+Obviously, versions can be compared:
+
+
+.. code-block:: pycon
+
+ >>> semantic_version.Version('0.1.1') < semantic_version.Version('0.1.2')
+ True
+ >>> semantic_version.Version('0.1.1') > semantic_version.Version('0.1.1-alpha')
+ True
+ >>> semantic_version.Version('0.1.1') <= semantic_version.Version('0.1.1-alpha')
+ False
+
+You can also get a new version that represents a bump in one of the version levels:
+
+.. code-block:: pycon
+
+ >>> v = semantic_version.Version('0.1.1+build')
+ >>> new_v = v.next_major()
+ >>> str(new_v)
+ '1.0.0'
+ >>> v = semantic_version.Version('1.1.1+build')
+ >>> new_v = v.next_minor()
+ >>> str(new_v)
+ '1.2.0'
+ >>> v = semantic_version.Version('1.1.1+build')
+ >>> new_v = v.next_patch()
+ >>> str(new_v)
+ '1.1.2'
+
+
+
+Requirement specification
+-------------------------
+
+python-semanticversion provides a couple of ways to describe a range of accepted
+versions:
+
+- The ``SimpleSpec`` class provides a simple, easily understood scheme --
+ somewhat inspired from PyPI range notations;
+- The ``NpmSpec`` class supports the whole NPM range specification scheme:
+
+ .. code-block:: pycon
+
+ >>> Version('0.1.2') in NpmSpec('0.1.0-alpha.2 .. 0.2.4')
+ True
+ >>> Version('0.1.2') in NpmSpec('>=0.1.1 <0.1.3 || 2.x')
+ True
+ >>> Version('2.3.4') in NpmSpec('>=0.1.1 <0.1.3 || 2.x')
+ True
+
+The ``SimpleSpec`` scheme
+"""""""""""""""""""""""""
+
+Basic usage is simply a comparator and a base version:
+
+.. code-block:: pycon
+
+ >>> s = SimpleSpec('>=0.1.1') # At least 0.1.1
+ >>> s.match(Version('0.1.1'))
+ True
+ >>> s.match(Version('0.1.1-alpha1')) # pre-release doesn't satisfy version spec
+ False
+ >>> s.match(Version('0.1.0'))
+ False
+
+Combining specifications can be expressed as follows:
+
+ .. code-block:: pycon
+
+ >>> SimpleSpec('>=0.1.1,<0.3.0')
+
+Simpler test syntax is also available using the ``in`` keyword:
+
+.. code-block:: pycon
+
+ >>> s = SimpleSpec('==0.1.1')
+ >>> Version('0.1.1+git7ccc72') in s # build variants are equivalent to full versions
+ True
+ >>> Version('0.1.1-alpha1') in s # pre-release variants don't match the full version.
+ False
+ >>> Version('0.1.2') in s
+ False
+
+
+Refer to the full documentation at
+https://python-semanticversion.readthedocs.io/en/latest/ for more details on the
+``SimpleSpec`` scheme.
+
+
+
+Using a specification
+"""""""""""""""""""""
+
+The ``SimpleSpec.filter`` method filters an iterable of ``Version``:
+
+.. code-block:: pycon
+
+ >>> s = SimpleSpec('>=0.1.0,<0.4.0')
+ >>> versions = (Version('0.%d.0' % i) for i in range(6))
+ >>> for v in s.filter(versions):
+ ... print v
+ 0.1.0
+ 0.2.0
+ 0.3.0
+
+It is also possible to select the 'best' version from such iterables:
+
+
+.. code-block:: pycon
+
+ >>> s = SimpleSpec('>=0.1.0,<0.4.0')
+ >>> versions = (Version('0.%d.0' % i) for i in range(6))
+ >>> s.select(versions)
+ Version('0.3.0')
+
+
+
+Contributing
+============
+
+In order to contribute to the source code:
+
+- Open an issue on `GitHub`_: https://github.com/rbarrois/python-semanticversion/issues
+- Fork the `repository `_
+ and submit a pull request on `GitHub`_
+- Or send me a patch (mailto:raphael.barrois+semver@polytechnique.org)
+
+When submitting patches or pull requests, you should respect the following rules:
+
+- Coding conventions are based on :pep:`8`
+- The whole test suite must pass after adding the changes
+- The test coverage for a new feature must be 100%
+- New features and methods should be documented in the ``reference`` section
+ and included in the ``changelog``
+- Include your name in the ``contributors`` section
+
+.. note:: All files should contain the following header::
+
+ # -*- encoding: utf-8 -*-
+ # Copyright (c) The python-semanticversion project
+
+.. _SemVer: http://semver.org/
+.. _PyPI: http://pypi.python.org/
diff --git a/third_party/semantic_version/__init__.py b/third_party/semantic_version/__init__.py
new file mode 100644
index 000000000..c50f30503
--- /dev/null
+++ b/third_party/semantic_version/__init__.py
@@ -0,0 +1,9 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) The python-semanticversion project
+# This code is distributed under the two-clause BSD License.
+
+
+from .base import compare, match, validate, SimpleSpec, NpmSpec, Spec, SpecItem, Version
+
+
+__author__ = "Raphaƫl Barrois "
diff --git a/third_party/semantic_version/base.py b/third_party/semantic_version/base.py
new file mode 100644
index 000000000..777c27ac4
--- /dev/null
+++ b/third_party/semantic_version/base.py
@@ -0,0 +1,1449 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) The python-semanticversion project
+# This code is distributed under the two-clause BSD License.
+
+import functools
+import re
+import warnings
+
+
+def _has_leading_zero(value):
+ return (value
+ and value[0] == '0'
+ and value.isdigit()
+ and value != '0')
+
+
+class MaxIdentifier(object):
+ __slots__ = []
+
+ def __repr__(self):
+ return 'MaxIdentifier()'
+
+ def __eq__(self, other):
+ return isinstance(other, self.__class__)
+
+
+@functools.total_ordering
+class NumericIdentifier(object):
+ __slots__ = ['value']
+
+ def __init__(self, value):
+ self.value = int(value)
+
+ def __repr__(self):
+ return 'NumericIdentifier(%r)' % self.value
+
+ def __eq__(self, other):
+ if isinstance(other, NumericIdentifier):
+ return self.value == other.value
+ return NotImplemented
+
+ def __lt__(self, other):
+ if isinstance(other, MaxIdentifier):
+ return True
+ elif isinstance(other, AlphaIdentifier):
+ return True
+ elif isinstance(other, NumericIdentifier):
+ return self.value < other.value
+ else:
+ return NotImplemented
+
+
+@functools.total_ordering
+class AlphaIdentifier(object):
+ __slots__ = ['value']
+
+ def __init__(self, value):
+ self.value = value.encode('ascii')
+
+ def __repr__(self):
+ return 'AlphaIdentifier(%r)' % self.value
+
+ def __eq__(self, other):
+ if isinstance(other, AlphaIdentifier):
+ return self.value == other.value
+ return NotImplemented
+
+ def __lt__(self, other):
+ if isinstance(other, MaxIdentifier):
+ return True
+ elif isinstance(other, NumericIdentifier):
+ return False
+ elif isinstance(other, AlphaIdentifier):
+ return self.value < other.value
+ else:
+ return NotImplemented
+
+
+class Version(object):
+
+ version_re = re.compile(r'^(\d+)\.(\d+)\.(\d+)(?:-([0-9a-zA-Z.-]+))?(?:\+([0-9a-zA-Z.-]+))?$')
+ partial_version_re = re.compile(r'^(\d+)(?:\.(\d+)(?:\.(\d+))?)?(?:-([0-9a-zA-Z.-]*))?(?:\+([0-9a-zA-Z.-]*))?$')
+
+ def __init__(
+ self,
+ version_string=None,
+ major=None,
+ minor=None,
+ patch=None,
+ prerelease=None,
+ build=None,
+ partial=False):
+ if partial:
+ warnings.warn(
+ "Partial versions will be removed in 3.0; use SimpleSpec('1.x.x') instead.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ has_text = version_string is not None
+ has_parts = not (major is minor is patch is prerelease is build is None)
+ if not has_text ^ has_parts:
+ raise ValueError("Call either Version('1.2.3') or Version(major=1, ...).")
+
+ if has_text:
+ major, minor, patch, prerelease, build = self.parse(version_string, partial)
+ else:
+ # Convenience: allow to omit prerelease/build.
+ prerelease = tuple(prerelease or ())
+ if not partial:
+ build = tuple(build or ())
+ self._validate_kwargs(major, minor, patch, prerelease, build, partial)
+
+ self.major = major
+ self.minor = minor
+ self.patch = patch
+ self.prerelease = prerelease
+ self.build = build
+
+ self.partial = partial
+
+ # Cached precedence keys
+ # _cmp_precedence_key is used for semver-precedence comparison
+ self._cmp_precedence_key = self._build_precedence_key(with_build=False)
+ # _sort_precedence_key is used for self.precedence_key, esp. for sorted(...)
+ self._sort_precedence_key = self._build_precedence_key(with_build=True)
+
+ @classmethod
+ def _coerce(cls, value, allow_none=False):
+ if value is None and allow_none:
+ return value
+ return int(value)
+
+ def next_major(self):
+ if self.prerelease and self.minor == self.patch == 0:
+ return Version(
+ major=self.major,
+ minor=0,
+ patch=0,
+ partial=self.partial,
+ )
+ else:
+ return Version(
+ major=self.major + 1,
+ minor=0,
+ patch=0,
+ partial=self.partial,
+ )
+
+ def next_minor(self):
+ if self.prerelease and self.patch == 0:
+ return Version(
+ major=self.major,
+ minor=self.minor,
+ patch=0,
+ partial=self.partial,
+ )
+ else:
+ return Version(
+ major=self.major,
+ minor=self.minor + 1,
+ patch=0,
+ partial=self.partial,
+ )
+
+ def next_patch(self):
+ if self.prerelease:
+ return Version(
+ major=self.major,
+ minor=self.minor,
+ patch=self.patch,
+ partial=self.partial,
+ )
+ else:
+ return Version(
+ major=self.major,
+ minor=self.minor,
+ patch=self.patch + 1,
+ partial=self.partial,
+ )
+
+ def truncate(self, level='patch'):
+ """Return a new Version object, truncated up to the selected level."""
+ if level == 'build':
+ return self
+ elif level == 'prerelease':
+ return Version(
+ major=self.major,
+ minor=self.minor,
+ patch=self.patch,
+ prerelease=self.prerelease,
+ partial=self.partial,
+ )
+ elif level == 'patch':
+ return Version(
+ major=self.major,
+ minor=self.minor,
+ patch=self.patch,
+ partial=self.partial,
+ )
+ elif level == 'minor':
+ return Version(
+ major=self.major,
+ minor=self.minor,
+ patch=None if self.partial else 0,
+ partial=self.partial,
+ )
+ elif level == 'major':
+ return Version(
+ major=self.major,
+ minor=None if self.partial else 0,
+ patch=None if self.partial else 0,
+ partial=self.partial,
+ )
+ else:
+ raise ValueError("Invalid truncation level `%s`." % level)
+
+ @classmethod
+ def coerce(cls, version_string, partial=False):
+ """Coerce an arbitrary version string into a semver-compatible one.
+
+ The rule is:
+ - If not enough components, fill minor/patch with zeroes; unless
+ partial=True
+ - If more than 3 dot-separated components, extra components are "build"
+ data. If some "build" data already appeared, append it to the
+ extra components
+
+ Examples:
+ >>> Version.coerce('0.1')
+ Version(0, 1, 0)
+ >>> Version.coerce('0.1.2.3')
+ Version(0, 1, 2, (), ('3',))
+ >>> Version.coerce('0.1.2.3+4')
+ Version(0, 1, 2, (), ('3', '4'))
+ >>> Version.coerce('0.1+2-3+4_5')
+ Version(0, 1, 0, (), ('2-3', '4-5'))
+ """
+ base_re = re.compile(r'^\d+(?:\.\d+(?:\.\d+)?)?')
+
+ match = base_re.match(version_string)
+ if not match:
+ raise ValueError(
+ "Version string lacks a numerical component: %r"
+ % version_string
+ )
+
+ version = version_string[:match.end()]
+ if not partial:
+ # We need a not-partial version.
+ while version.count('.') < 2:
+ version += '.0'
+
+ # Strip leading zeros in components
+ # Version is of the form nn, nn.pp or nn.pp.qq
+ version = '.'.join(
+ # If the part was '0', we end up with an empty string.
+ part.lstrip('0') or '0'
+ for part in version.split('.')
+ )
+
+ if match.end() == len(version_string):
+ return Version(version, partial=partial)
+
+ rest = version_string[match.end():]
+
+ # Cleanup the 'rest'
+ rest = re.sub(r'[^a-zA-Z0-9+.-]', '-', rest)
+
+ if rest[0] == '+':
+ # A 'build' component
+ prerelease = ''
+ build = rest[1:]
+ elif rest[0] == '.':
+ # An extra version component, probably 'build'
+ prerelease = ''
+ build = rest[1:]
+ elif rest[0] == '-':
+ rest = rest[1:]
+ if '+' in rest:
+ prerelease, build = rest.split('+', 1)
+ else:
+ prerelease, build = rest, ''
+ elif '+' in rest:
+ prerelease, build = rest.split('+', 1)
+ else:
+ prerelease, build = rest, ''
+
+ build = build.replace('+', '.')
+
+ if prerelease:
+ version = '%s-%s' % (version, prerelease)
+ if build:
+ version = '%s+%s' % (version, build)
+
+ return cls(version, partial=partial)
+
+ @classmethod
+ def parse(cls, version_string, partial=False, coerce=False):
+ """Parse a version string into a tuple of components:
+ (major, minor, patch, prerelease, build).
+
+ Args:
+ version_string (str), the version string to parse
+ partial (bool), whether to accept incomplete input
+ coerce (bool), whether to try to map the passed in string into a
+ valid Version.
+ """
+ if not version_string:
+ raise ValueError('Invalid empty version string: %r' % version_string)
+
+ if partial:
+ version_re = cls.partial_version_re
+ else:
+ version_re = cls.version_re
+
+ match = version_re.match(version_string)
+ if not match:
+ raise ValueError('Invalid version string: %r' % version_string)
+
+ major, minor, patch, prerelease, build = match.groups()
+
+ if _has_leading_zero(major):
+ raise ValueError("Invalid leading zero in major: %r" % version_string)
+ if _has_leading_zero(minor):
+ raise ValueError("Invalid leading zero in minor: %r" % version_string)
+ if _has_leading_zero(patch):
+ raise ValueError("Invalid leading zero in patch: %r" % version_string)
+
+ major = int(major)
+ minor = cls._coerce(minor, partial)
+ patch = cls._coerce(patch, partial)
+
+ if prerelease is None:
+ if partial and (build is None):
+ # No build info, strip here
+ return (major, minor, patch, None, None)
+ else:
+ prerelease = ()
+ elif prerelease == '':
+ prerelease = ()
+ else:
+ prerelease = tuple(prerelease.split('.'))
+ cls._validate_identifiers(prerelease, allow_leading_zeroes=False)
+
+ if build is None:
+ if partial:
+ build = None
+ else:
+ build = ()
+ elif build == '':
+ build = ()
+ else:
+ build = tuple(build.split('.'))
+ cls._validate_identifiers(build, allow_leading_zeroes=True)
+
+ return (major, minor, patch, prerelease, build)
+
+ @classmethod
+ def _validate_identifiers(cls, identifiers, allow_leading_zeroes=False):
+ for item in identifiers:
+ if not item:
+ raise ValueError(
+ "Invalid empty identifier %r in %r"
+ % (item, '.'.join(identifiers))
+ )
+
+ if item[0] == '0' and item.isdigit() and item != '0' and not allow_leading_zeroes:
+ raise ValueError("Invalid leading zero in identifier %r" % item)
+
+ @classmethod
+ def _validate_kwargs(cls, major, minor, patch, prerelease, build, partial):
+ if (
+ major != int(major)
+ or minor != cls._coerce(minor, partial)
+ or patch != cls._coerce(patch, partial)
+ or prerelease is None and not partial
+ or build is None and not partial
+ ):
+ raise ValueError(
+ "Invalid kwargs to Version(major=%r, minor=%r, patch=%r, "
+ "prerelease=%r, build=%r, partial=%r" % (
+ major, minor, patch, prerelease, build, partial
+ ))
+ if prerelease is not None:
+ cls._validate_identifiers(prerelease, allow_leading_zeroes=False)
+ if build is not None:
+ cls._validate_identifiers(build, allow_leading_zeroes=True)
+
+ def __iter__(self):
+ return iter((self.major, self.minor, self.patch, self.prerelease, self.build))
+
+ def __str__(self):
+ version = '%d' % self.major
+ if self.minor is not None:
+ version = '%s.%d' % (version, self.minor)
+ if self.patch is not None:
+ version = '%s.%d' % (version, self.patch)
+
+ if self.prerelease or (self.partial and self.prerelease == () and self.build is None):
+ version = '%s-%s' % (version, '.'.join(self.prerelease))
+ if self.build or (self.partial and self.build == ()):
+ version = '%s+%s' % (version, '.'.join(self.build))
+ return version
+
+ def __repr__(self):
+ return '%s(%r%s)' % (
+ self.__class__.__name__,
+ str(self),
+ ', partial=True' if self.partial else '',
+ )
+
+ def __hash__(self):
+ # We don't include 'partial', since this is strictly equivalent to having
+ # at least a field being `None`.
+ return hash((self.major, self.minor, self.patch, self.prerelease, self.build))
+
+ def _build_precedence_key(self, with_build=False):
+ """Build a precedence key.
+
+ The "build" component should only be used when sorting an iterable
+ of versions.
+ """
+ if self.prerelease:
+ prerelease_key = tuple(
+ NumericIdentifier(part) if part.isdigit() else AlphaIdentifier(part)
+ for part in self.prerelease
+ )
+ else:
+ prerelease_key = (
+ MaxIdentifier(),
+ )
+
+ if not with_build:
+ return (
+ self.major,
+ self.minor,
+ self.patch,
+ prerelease_key,
+ )
+
+ build_key = tuple(
+ NumericIdentifier(part) if part.isdigit() else AlphaIdentifier(part)
+ for part in self.build or ()
+ )
+
+ return (
+ self.major,
+ self.minor,
+ self.patch,
+ prerelease_key,
+ build_key,
+ )
+
+ @property
+ def precedence_key(self):
+ return self._sort_precedence_key
+
+ def __cmp__(self, other):
+ if not isinstance(other, self.__class__):
+ return NotImplemented
+ if self < other:
+ return -1
+ elif self > other:
+ return 1
+ elif self == other:
+ return 0
+ else:
+ return NotImplemented
+
+ def __eq__(self, other):
+ if not isinstance(other, self.__class__):
+ return NotImplemented
+ return (
+ self.major == other.major
+ and self.minor == other.minor
+ and self.patch == other.patch
+ and (self.prerelease or ()) == (other.prerelease or ())
+ and (self.build or ()) == (other.build or ())
+ )
+
+ def __ne__(self, other):
+ if not isinstance(other, self.__class__):
+ return NotImplemented
+ return tuple(self) != tuple(other)
+
+ def __lt__(self, other):
+ if not isinstance(other, self.__class__):
+ return NotImplemented
+ return self._cmp_precedence_key < other._cmp_precedence_key
+
+ def __le__(self, other):
+ if not isinstance(other, self.__class__):
+ return NotImplemented
+ return self._cmp_precedence_key <= other._cmp_precedence_key
+
+ def __gt__(self, other):
+ if not isinstance(other, self.__class__):
+ return NotImplemented
+ return self._cmp_precedence_key > other._cmp_precedence_key
+
+ def __ge__(self, other):
+ if not isinstance(other, self.__class__):
+ return NotImplemented
+ return self._cmp_precedence_key >= other._cmp_precedence_key
+
+
+class SpecItem(object):
+ """A requirement specification."""
+
+ KIND_ANY = '*'
+ KIND_LT = '<'
+ KIND_LTE = '<='
+ KIND_EQUAL = '=='
+ KIND_SHORTEQ = '='
+ KIND_EMPTY = ''
+ KIND_GTE = '>='
+ KIND_GT = '>'
+ KIND_NEQ = '!='
+ KIND_CARET = '^'
+ KIND_TILDE = '~'
+ KIND_COMPATIBLE = '~='
+
+ # Map a kind alias to its full version
+ KIND_ALIASES = {
+ KIND_SHORTEQ: KIND_EQUAL,
+ KIND_EMPTY: KIND_EQUAL,
+ }
+
+ re_spec = re.compile(r'^(<|<=||=|==|>=|>|!=|\^|~|~=)(\d.*)$')
+
+ def __init__(self, requirement_string, _warn=True):
+ if _warn:
+ warnings.warn(
+ "The `SpecItem` class will be removed in 3.0.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ kind, spec = self.parse(requirement_string)
+ self.kind = kind
+ self.spec = spec
+ self._clause = Spec(requirement_string).clause
+
+ @classmethod
+ def parse(cls, requirement_string):
+ if not requirement_string:
+ raise ValueError("Invalid empty requirement specification: %r" % requirement_string)
+
+ # Special case: the 'any' version spec.
+ if requirement_string == '*':
+ return (cls.KIND_ANY, '')
+
+ match = cls.re_spec.match(requirement_string)
+ if not match:
+ raise ValueError("Invalid requirement specification: %r" % requirement_string)
+
+ kind, version = match.groups()
+ if kind in cls.KIND_ALIASES:
+ kind = cls.KIND_ALIASES[kind]
+
+ spec = Version(version, partial=True)
+ if spec.build is not None and kind not in (cls.KIND_EQUAL, cls.KIND_NEQ):
+ raise ValueError(
+ "Invalid requirement specification %r: build numbers have no ordering."
+ % requirement_string
+ )
+ return (kind, spec)
+
+ @classmethod
+ def from_matcher(cls, matcher):
+ if matcher == Always():
+ return cls('*', _warn=False)
+ elif matcher == Never():
+ return cls('<0.0.0-', _warn=False)
+ elif isinstance(matcher, Range):
+ return cls('%s%s' % (matcher.operator, matcher.target), _warn=False)
+
+ def match(self, version):
+ return self._clause.match(version)
+
+ def __str__(self):
+ return '%s%s' % (self.kind, self.spec)
+
+ def __repr__(self):
+ return '' % (self.kind, self.spec)
+
+ def __eq__(self, other):
+ if not isinstance(other, SpecItem):
+ return NotImplemented
+ return self.kind == other.kind and self.spec == other.spec
+
+ def __hash__(self):
+ return hash((self.kind, self.spec))
+
+
+def compare(v1, v2):
+ return Version(v1).__cmp__(Version(v2))
+
+
+def match(spec, version):
+ return Spec(spec).match(Version(version))
+
+
+def validate(version_string):
+ """Validates a version string againt the SemVer specification."""
+ try:
+ Version.parse(version_string)
+ return True
+ except ValueError:
+ return False
+
+
+DEFAULT_SYNTAX = 'simple'
+
+
+class BaseSpec(object):
+ """A specification of compatible versions.
+
+ Usage:
+ >>> Spec('>=1.0.0', syntax='npm')
+
+ A version matches a specification if it matches any
+ of the clauses of that specification.
+
+ Internally, a Spec is AnyOf(
+ AllOf(Matcher, Matcher, Matcher),
+ AllOf(...),
+ )
+ """
+ SYNTAXES = {}
+
+ @classmethod
+ def register_syntax(cls, subclass):
+ syntax = subclass.SYNTAX
+ if syntax is None:
+ raise ValueError("A Spec needs its SYNTAX field to be set.")
+ elif syntax in cls.SYNTAXES:
+ raise ValueError(
+ "Duplicate syntax for %s: %r, %r"
+ % (syntax, cls.SYNTAXES[syntax], subclass)
+ )
+ cls.SYNTAXES[syntax] = subclass
+ return subclass
+
+ def __init__(self, expression):
+ super(BaseSpec, self).__init__()
+ self.expression = expression
+ self.clause = self._parse_to_clause(expression)
+
+ @classmethod
+ def parse(cls, expression, syntax=DEFAULT_SYNTAX):
+ """Convert a syntax-specific expression into a BaseSpec instance."""
+ return cls.SYNTAXES[syntax](expression)
+
+ @classmethod
+ def _parse_to_clause(cls, expression):
+ """Converts an expression to a clause."""
+ raise NotImplementedError()
+
+ def filter(self, versions):
+ """Filter an iterable of versions satisfying the Spec."""
+ for version in versions:
+ if self.match(version):
+ yield version
+
+ def match(self, version):
+ """Check whether a Version satisfies the Spec."""
+ return self.clause.match(version)
+
+ def select(self, versions):
+ """Select the best compatible version among an iterable of options."""
+ options = list(self.filter(versions))
+ if options:
+ return max(options)
+ return None
+
+ def __contains__(self, version):
+ """Whether `version in self`."""
+ if isinstance(version, Version):
+ return self.match(version)
+ return False
+
+ def __eq__(self, other):
+ if not isinstance(other, self.__class__):
+ return NotImplemented
+
+ return self.clause == other.clause
+
+ def __hash__(self):
+ return hash(self.clause)
+
+ def __str__(self):
+ return self.expression
+
+ def __repr__(self):
+ return '<%s: %r>' % (self.__class__.__name__, self.expression)
+
+
+class Clause(object):
+ __slots__ = []
+
+ def match(self, version):
+ raise NotImplementedError()
+
+ def __and__(self, other):
+ raise NotImplementedError()
+
+ def __or__(self, other):
+ raise NotImplementedError()
+
+ def __eq__(self, other):
+ raise NotImplementedError()
+
+ def prettyprint(self, indent='\t'):
+ """Pretty-print the clause.
+ """
+ return '\n'.join(self._pretty()).replace('\t', indent)
+
+ def _pretty(self):
+ """Actual pretty-printing logic.
+
+ Yields:
+ A list of string. Indentation is performed with \t.
+ """
+ yield repr(self)
+
+ def __ne__(self, other):
+ return not self == other
+
+ def simplify(self):
+ return self
+
+
+class AnyOf(Clause):
+ __slots__ = ['clauses']
+
+ def __init__(self, *clauses):
+ super(AnyOf, self).__init__()
+ self.clauses = frozenset(clauses)
+
+ def match(self, version):
+ return any(c.match(version) for c in self.clauses)
+
+ def simplify(self):
+ subclauses = set()
+ for clause in self.clauses:
+ simplified = clause.simplify()
+ if isinstance(simplified, AnyOf):
+ subclauses |= simplified.clauses
+ elif simplified == Never():
+ continue
+ else:
+ subclauses.add(simplified)
+ if len(subclauses) == 1:
+ return subclauses.pop()
+ return AnyOf(*subclauses)
+
+ def __hash__(self):
+ return hash((AnyOf, self.clauses))
+
+ def __iter__(self):
+ return iter(self.clauses)
+
+ def __eq__(self, other):
+ return isinstance(other, self.__class__) and self.clauses == other.clauses
+
+ def __and__(self, other):
+ if isinstance(other, AllOf):
+ return other & self
+ elif isinstance(other, Matcher) or isinstance(other, AnyOf):
+ return AllOf(self, other)
+ else:
+ return NotImplemented
+
+ def __or__(self, other):
+ if isinstance(other, AnyOf):
+ clauses = list(self.clauses | other.clauses)
+ elif isinstance(other, Matcher) or isinstance(other, AllOf):
+ clauses = list(self.clauses | set([other]))
+ else:
+ return NotImplemented
+ return AnyOf(*clauses)
+
+ def __repr__(self):
+ return 'AnyOf(%s)' % ', '.join(sorted(repr(c) for c in self.clauses))
+
+ def _pretty(self):
+ yield 'AnyOF('
+ for clause in self.clauses:
+ lines = list(clause._pretty())
+ for line in lines[:-1]:
+ yield '\t' + line
+ yield '\t' + lines[-1] + ','
+ yield ')'
+
+
+class AllOf(Clause):
+ __slots__ = ['clauses']
+
+ def __init__(self, *clauses):
+ super(AllOf, self).__init__()
+ self.clauses = frozenset(clauses)
+
+ def match(self, version):
+ return all(clause.match(version) for clause in self.clauses)
+
+ def simplify(self):
+ subclauses = set()
+ for clause in self.clauses:
+ simplified = clause.simplify()
+ if isinstance(simplified, AllOf):
+ subclauses |= simplified.clauses
+ elif simplified == Always():
+ continue
+ else:
+ subclauses.add(simplified)
+ if len(subclauses) == 1:
+ return subclauses.pop()
+ return AllOf(*subclauses)
+
+ def __hash__(self):
+ return hash((AllOf, self.clauses))
+
+ def __iter__(self):
+ return iter(self.clauses)
+
+ def __eq__(self, other):
+ return isinstance(other, self.__class__) and self.clauses == other.clauses
+
+ def __and__(self, other):
+ if isinstance(other, Matcher) or isinstance(other, AnyOf):
+ clauses = list(self.clauses | set([other]))
+ elif isinstance(other, AllOf):
+ clauses = list(self.clauses | other.clauses)
+ else:
+ return NotImplemented
+ return AllOf(*clauses)
+
+ def __or__(self, other):
+ if isinstance(other, AnyOf):
+ return other | self
+ elif isinstance(other, Matcher):
+ return AnyOf(self, AllOf(other))
+ elif isinstance(other, AllOf):
+ return AnyOf(self, other)
+ else:
+ return NotImplemented
+
+ def __repr__(self):
+ return 'AllOf(%s)' % ', '.join(sorted(repr(c) for c in self.clauses))
+
+ def _pretty(self):
+ yield 'AllOF('
+ for clause in self.clauses:
+ lines = list(clause._pretty())
+ for line in lines[:-1]:
+ yield '\t' + line
+ yield '\t' + lines[-1] + ','
+ yield ')'
+
+
+class Matcher(Clause):
+ __slots__ = []
+
+ def __and__(self, other):
+ if isinstance(other, AllOf):
+ return other & self
+ elif isinstance(other, Matcher) or isinstance(other, AnyOf):
+ return AllOf(self, other)
+ else:
+ return NotImplemented
+
+ def __or__(self, other):
+ if isinstance(other, AnyOf):
+ return other | self
+ elif isinstance(other, Matcher) or isinstance(other, AllOf):
+ return AnyOf(self, other)
+ else:
+ return NotImplemented
+
+
+class Never(Matcher):
+ __slots__ = []
+
+ def match(self, version):
+ return False
+
+ def __hash__(self):
+ return hash((Never,))
+
+ def __eq__(self, other):
+ return isinstance(other, self.__class__)
+
+ def __and__(self, other):
+ return self
+
+ def __or__(self, other):
+ return other
+
+ def __repr__(self):
+ return 'Never()'
+
+
+class Always(Matcher):
+ __slots__ = []
+
+ def match(self, version):
+ return True
+
+ def __hash__(self):
+ return hash((Always,))
+
+ def __eq__(self, other):
+ return isinstance(other, self.__class__)
+
+ def __and__(self, other):
+ return other
+
+ def __or__(self, other):
+ return self
+
+ def __repr__(self):
+ return 'Always()'
+
+
+class Range(Matcher):
+ OP_EQ = '=='
+ OP_GT = '>'
+ OP_GTE = '>='
+ OP_LT = '<'
+ OP_LTE = '<='
+ OP_NEQ = '!='
+
+ # <1.2.3 matches 1.2.3-a1
+ PRERELEASE_ALWAYS = 'always'
+ # <1.2.3 does not match 1.2.3-a1
+ PRERELEASE_NATURAL = 'natural'
+ # 1.2.3-a1 is only considered if target == 1.2.3-xxx
+ PRERELEASE_SAMEPATCH = 'same-patch'
+
+ # 1.2.3 matches 1.2.3+*
+ BUILD_IMPLICIT = 'implicit'
+ # 1.2.3 matches only 1.2.3, not 1.2.3+4
+ BUILD_STRICT = 'strict'
+
+ __slots__ = ['operator', 'target', 'prerelease_policy', 'build_policy']
+
+ def __init__(self, operator, target, prerelease_policy=PRERELEASE_NATURAL, build_policy=BUILD_IMPLICIT):
+ super(Range, self).__init__()
+ if target.build and operator not in (self.OP_EQ, self.OP_NEQ):
+ raise ValueError(
+ "Invalid range %s%s: build numbers have no ordering."
+ % (operator, target))
+ self.operator = operator
+ self.target = target
+ self.prerelease_policy = prerelease_policy
+ self.build_policy = self.BUILD_STRICT if target.build else build_policy
+
+ def match(self, version):
+ if self.build_policy != self.BUILD_STRICT:
+ version = version.truncate('prerelease')
+
+ if version.prerelease:
+ same_patch = self.target.truncate() == version.truncate()
+
+ if self.prerelease_policy == self.PRERELEASE_SAMEPATCH and not same_patch:
+ return False
+
+ if self.operator == self.OP_EQ:
+ if self.build_policy == self.BUILD_STRICT:
+ return (
+ self.target.truncate('prerelease') == version.truncate('prerelease')
+ and version.build == self.target.build
+ )
+ return version == self.target
+ elif self.operator == self.OP_GT:
+ return version > self.target
+ elif self.operator == self.OP_GTE:
+ return version >= self.target
+ elif self.operator == self.OP_LT:
+ if (
+ version.prerelease
+ and self.prerelease_policy == self.PRERELEASE_NATURAL
+ and version.truncate() == self.target.truncate()
+ and not self.target.prerelease
+ ):
+ return False
+ return version < self.target
+ elif self.operator == self.OP_LTE:
+ return version <= self.target
+ else:
+ assert self.operator == self.OP_NEQ
+ if self.build_policy == self.BUILD_STRICT:
+ return not (
+ self.target.truncate('prerelease') == version.truncate('prerelease')
+ and version.build == self.target.build
+ )
+
+ if (
+ version.prerelease
+ and self.prerelease_policy == self.PRERELEASE_NATURAL
+ and version.truncate() == self.target.truncate()
+ and not self.target.prerelease
+ ):
+ return False
+ return version != self.target
+
+ def __hash__(self):
+ return hash((Range, self.operator, self.target, self.prerelease_policy))
+
+ def __eq__(self, other):
+ return (
+ isinstance(other, self.__class__)
+ and self.operator == other.operator
+ and self.target == other.target
+ and self.prerelease_policy == other.prerelease_policy
+ )
+
+ def __str__(self):
+ return '%s%s' % (self.operator, self.target)
+
+ def __repr__(self):
+ policy_part = (
+ '' if self.prerelease_policy == self.PRERELEASE_NATURAL
+ else ', prerelease_policy=%r' % self.prerelease_policy
+ ) + (
+ '' if self.build_policy == self.BUILD_IMPLICIT
+ else ', build_policy=%r' % self.build_policy
+ )
+ return 'Range(%r, %r%s)' % (
+ self.operator,
+ self.target,
+ policy_part,
+ )
+
+
+@BaseSpec.register_syntax
+class SimpleSpec(BaseSpec):
+
+ SYNTAX = 'simple'
+
+ @classmethod
+ def _parse_to_clause(cls, expression):
+ return cls.Parser.parse(expression)
+
+ class Parser:
+ NUMBER = r'\*|0|[1-9][0-9]*'
+ NAIVE_SPEC = re.compile(r"""^
+ (?P<|<=||=|==|>=|>|!=|\^|~|~=)
+ (?P{nb})(?:\.(?P{nb})(?:\.(?P{nb}))?)?
+ (?:-(?P[a-z0-9A-Z.-]*))?
+ (?:\+(?P[a-z0-9A-Z.-]*))?
+ $
+ """.format(nb=NUMBER),
+ re.VERBOSE,
+ )
+
+ @classmethod
+ def parse(cls, expression):
+ blocks = expression.split(',')
+ clause = Always()
+ for block in blocks:
+ if not cls.NAIVE_SPEC.match(block):
+ raise ValueError("Invalid simple block %r" % block)
+ clause &= cls.parse_block(block)
+
+ return clause
+
+ PREFIX_CARET = '^'
+ PREFIX_TILDE = '~'
+ PREFIX_COMPATIBLE = '~='
+ PREFIX_EQ = '=='
+ PREFIX_NEQ = '!='
+ PREFIX_GT = '>'
+ PREFIX_GTE = '>='
+ PREFIX_LT = '<'
+ PREFIX_LTE = '<='
+
+ PREFIX_ALIASES = {
+ '=': PREFIX_EQ,
+ '': PREFIX_EQ,
+ }
+
+ EMPTY_VALUES = ['*', 'x', 'X', None]
+
+ @classmethod
+ def parse_block(cls, expr):
+ if not cls.NAIVE_SPEC.match(expr):
+ raise ValueError("Invalid simple spec component: %r" % expr)
+ prefix, major_t, minor_t, patch_t, prerel, build = cls.NAIVE_SPEC.match(expr).groups()
+ prefix = cls.PREFIX_ALIASES.get(prefix, prefix)
+
+ major = None if major_t in cls.EMPTY_VALUES else int(major_t)
+ minor = None if minor_t in cls.EMPTY_VALUES else int(minor_t)
+ patch = None if patch_t in cls.EMPTY_VALUES else int(patch_t)
+
+ if major is None: # '*'
+ target = Version(major=0, minor=0, patch=0)
+ if prefix not in (cls.PREFIX_EQ, cls.PREFIX_GTE):
+ raise ValueError("Invalid simple spec: %r" % expr)
+ elif minor is None:
+ target = Version(major=major, minor=0, patch=0)
+ elif patch is None:
+ target = Version(major=major, minor=minor, patch=0)
+ else:
+ target = Version(
+ major=major,
+ minor=minor,
+ patch=patch,
+ prerelease=prerel.split('.') if prerel else (),
+ build=build.split('.') if build else (),
+ )
+
+ if (major is None or minor is None or patch is None) and (prerel or build):
+ raise ValueError("Invalid simple spec: %r" % expr)
+
+ if build is not None and prefix not in (cls.PREFIX_EQ, cls.PREFIX_NEQ):
+ raise ValueError("Invalid simple spec: %r" % expr)
+
+ if prefix == cls.PREFIX_CARET:
+ # Accept anything with the same most-significant digit
+ if target.major:
+ high = target.next_major()
+ elif target.minor:
+ high = target.next_minor()
+ else:
+ high = target.next_patch()
+ return Range(Range.OP_GTE, target) & Range(Range.OP_LT, high)
+
+ elif prefix == cls.PREFIX_TILDE:
+ assert major is not None
+ # Accept any higher patch in the same minor
+ # Might go higher if the initial version was a partial
+ if minor is None:
+ high = target.next_major()
+ else:
+ high = target.next_minor()
+ return Range(Range.OP_GTE, target) & Range(Range.OP_LT, high)
+
+ elif prefix == cls.PREFIX_COMPATIBLE:
+ assert major is not None
+ # ~1 is 1.0.0..2.0.0; ~=2.2 is 2.2.0..3.0.0; ~=1.4.5 is 1.4.5..1.5.0
+ if minor is None or patch is None:
+ # We got a partial version
+ high = target.next_major()
+ else:
+ high = target.next_minor()
+ return Range(Range.OP_GTE, target) & Range(Range.OP_LT, high)
+
+ elif prefix == cls.PREFIX_EQ:
+ if major is None:
+ return Range(Range.OP_GTE, target)
+ elif minor is None:
+ return Range(Range.OP_GTE, target) & Range(Range.OP_LT, target.next_major())
+ elif patch is None:
+ return Range(Range.OP_GTE, target) & Range(Range.OP_LT, target.next_minor())
+ elif build == '':
+ return Range(Range.OP_EQ, target, build_policy=Range.BUILD_STRICT)
+ else:
+ return Range(Range.OP_EQ, target)
+
+ elif prefix == cls.PREFIX_NEQ:
+ assert major is not None
+ if minor is None:
+ # !=1.x => <1.0.0 || >=2.0.0
+ return Range(Range.OP_LT, target) | Range(Range.OP_GTE, target.next_major())
+ elif patch is None:
+ # !=1.2.x => <1.2.0 || >=1.3.0
+ return Range(Range.OP_LT, target) | Range(Range.OP_GTE, target.next_minor())
+ elif prerel == '':
+ # !=1.2.3-
+ return Range(Range.OP_NEQ, target, prerelease_policy=Range.PRERELEASE_ALWAYS)
+ elif build == '':
+ # !=1.2.3+ or !=1.2.3-a2+
+ return Range(Range.OP_NEQ, target, build_policy=Range.BUILD_STRICT)
+ else:
+ return Range(Range.OP_NEQ, target)
+
+ elif prefix == cls.PREFIX_GT:
+ assert major is not None
+ if minor is None:
+ # >1.x => >=2.0
+ return Range(Range.OP_GTE, target.next_major())
+ elif patch is None:
+ return Range(Range.OP_GTE, target.next_minor())
+ else:
+ return Range(Range.OP_GT, target)
+
+ elif prefix == cls.PREFIX_GTE:
+ return Range(Range.OP_GTE, target)
+
+ elif prefix == cls.PREFIX_LT:
+ assert major is not None
+ if prerel == '':
+ # <1.2.3-
+ return Range(Range.OP_LT, target, prerelease_policy=Range.PRERELEASE_ALWAYS)
+ return Range(Range.OP_LT, target)
+
+ else:
+ assert prefix == cls.PREFIX_LTE
+ assert major is not None
+ if minor is None:
+ # <=1.x => <2.0
+ return Range(Range.OP_LT, target.next_major())
+ elif patch is None:
+ return Range(Range.OP_LT, target.next_minor())
+ else:
+ return Range(Range.OP_LTE, target)
+
+
+class LegacySpec(SimpleSpec):
+ def __init__(self, *expressions):
+ warnings.warn(
+ "The Spec() class will be removed in 3.1; use SimpleSpec() instead.",
+ PendingDeprecationWarning,
+ stacklevel=2,
+ )
+
+ if len(expressions) > 1:
+ warnings.warn(
+ "Passing 2+ arguments to SimpleSpec will be removed in 3.0; concatenate them with ',' instead.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ expression = ','.join(expressions)
+ super(LegacySpec, self).__init__(expression)
+
+ @property
+ def specs(self):
+ return list(self)
+
+ def __iter__(self):
+ warnings.warn(
+ "Iterating over the components of a SimpleSpec object will be removed in 3.0.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ try:
+ clauses = list(self.clause)
+ except TypeError: # Not an iterable
+ clauses = [self.clause]
+ for clause in clauses:
+ yield SpecItem.from_matcher(clause)
+
+
+Spec = LegacySpec
+
+
+@BaseSpec.register_syntax
+class NpmSpec(BaseSpec):
+ SYNTAX = 'npm'
+
+ @classmethod
+ def _parse_to_clause(cls, expression):
+ return cls.Parser.parse(expression)
+
+ class Parser:
+ JOINER = '||'
+ HYPHEN = ' - '
+
+ NUMBER = r'x|X|\*|0|[1-9][0-9]*'
+ PART = r'[a-zA-Z0-9.-]*'
+ NPM_SPEC_BLOCK = re.compile(r"""
+ ^(?:v)? # Strip optional initial v
+ (?P<|<=|>=|>|=|\^|~|) # Operator, can be empty
+ (?P{nb})(?:\.(?P{nb})(?:\.(?P{nb}))?)?
+ (?:-(?P{part}))? # Optional re-release
+ (?:\+(?P{part}))? # Optional build
+ $""".format(nb=NUMBER, part=PART),
+ re.VERBOSE,
+ )
+
+ @classmethod
+ def range(cls, operator, target):
+ return Range(operator, target, prerelease_policy=Range.PRERELEASE_SAMEPATCH)
+
+ @classmethod
+ def parse(cls, expression):
+ result = Never()
+ groups = expression.split(cls.JOINER)
+ for group in groups:
+ group = group.strip()
+ if not group:
+ group = '>=0.0.0'
+
+ subclauses = []
+ if cls.HYPHEN in group:
+ low, high = group.split(cls.HYPHEN, 2)
+ subclauses = cls.parse_simple('>=' + low) + cls.parse_simple('<=' + high)
+
+ else:
+ blocks = group.split(' ')
+ for block in blocks:
+ if not cls.NPM_SPEC_BLOCK.match(block):
+ raise ValueError("Invalid NPM block in %r: %r" % (expression, block))
+
+ subclauses.extend(cls.parse_simple(block))
+
+ prerelease_clauses = []
+ non_prerel_clauses = []
+ for clause in subclauses:
+ if clause.target.prerelease:
+ if clause.operator in (Range.OP_GT, Range.OP_GTE):
+ prerelease_clauses.append(Range(
+ operator=Range.OP_LT,
+ target=Version(
+ major=clause.target.major,
+ minor=clause.target.minor,
+ patch=clause.target.patch + 1,
+ ),
+ prerelease_policy=Range.PRERELEASE_ALWAYS,
+ ))
+ elif clause.operator in (Range.OP_LT, Range.OP_LTE):
+ prerelease_clauses.append(Range(
+ operator=Range.OP_GTE,
+ target=Version(
+ major=clause.target.major,
+ minor=clause.target.minor,
+ patch=0,
+ prerelease=(),
+ ),
+ prerelease_policy=Range.PRERELEASE_ALWAYS,
+ ))
+ prerelease_clauses.append(clause)
+ non_prerel_clauses.append(cls.range(
+ operator=clause.operator,
+ target=clause.target.truncate(),
+ ))
+ else:
+ non_prerel_clauses.append(clause)
+ if prerelease_clauses:
+ result |= AllOf(*prerelease_clauses)
+ result |= AllOf(*non_prerel_clauses)
+
+ return result
+
+ PREFIX_CARET = '^'
+ PREFIX_TILDE = '~'
+ PREFIX_EQ = '='
+ PREFIX_GT = '>'
+ PREFIX_GTE = '>='
+ PREFIX_LT = '<'
+ PREFIX_LTE = '<='
+
+ PREFIX_ALIASES = {
+ '': PREFIX_EQ,
+ }
+
+ PREFIX_TO_OPERATOR = {
+ PREFIX_EQ: Range.OP_EQ,
+ PREFIX_LT: Range.OP_LT,
+ PREFIX_LTE: Range.OP_LTE,
+ PREFIX_GTE: Range.OP_GTE,
+ PREFIX_GT: Range.OP_GT,
+ }
+
+ EMPTY_VALUES = ['*', 'x', 'X', None]
+
+ @classmethod
+ def parse_simple(cls, simple):
+ match = cls.NPM_SPEC_BLOCK.match(simple)
+
+ prefix, major_t, minor_t, patch_t, prerel, build = match.groups()
+
+ prefix = cls.PREFIX_ALIASES.get(prefix, prefix)
+ major = None if major_t in cls.EMPTY_VALUES else int(major_t)
+ minor = None if minor_t in cls.EMPTY_VALUES else int(minor_t)
+ patch = None if patch_t in cls.EMPTY_VALUES else int(patch_t)
+
+ if build is not None and prefix not in [cls.PREFIX_EQ]:
+ # Ignore the 'build' part when not comparing to a specific part.
+ build = None
+
+ if major is None: # '*', 'x', 'X'
+ target = Version(major=0, minor=0, patch=0)
+ if prefix not in [cls.PREFIX_EQ, cls.PREFIX_GTE]:
+ raise ValueError("Invalid expression %r" % simple)
+ prefix = cls.PREFIX_GTE
+ elif minor is None:
+ target = Version(major=major, minor=0, patch=0)
+ elif patch is None:
+ target = Version(major=major, minor=minor, patch=0)
+ else:
+ target = Version(
+ major=major,
+ minor=minor,
+ patch=patch,
+ prerelease=prerel.split('.') if prerel else (),
+ build=build.split('.') if build else (),
+ )
+
+ if (major is None or minor is None or patch is None) and (prerel or build):
+ raise ValueError("Invalid NPM spec: %r" % simple)
+
+ if prefix == cls.PREFIX_CARET:
+ if target.major: # ^1.2.4 => >=1.2.4 <2.0.0 ; ^1.x => >=1.0.0 <2.0.0
+ high = target.truncate().next_major()
+ elif target.minor: # ^0.1.2 => >=0.1.2 <0.2.0
+ high = target.truncate().next_minor()
+ elif minor is None: # ^0.x => >=0.0.0 <1.0.0
+ high = target.truncate().next_major()
+ elif patch is None: # ^0.2.x => >=0.2.0 <0.3.0
+ high = target.truncate().next_minor()
+ else: # ^0.0.1 => >=0.0.1 <0.0.2
+ high = target.truncate().next_patch()
+ return [cls.range(Range.OP_GTE, target), cls.range(Range.OP_LT, high)]
+
+ elif prefix == cls.PREFIX_TILDE:
+ assert major is not None
+ if minor is None: # ~1.x => >=1.0.0 <2.0.0
+ high = target.next_major()
+ else: # ~1.2.x => >=1.2.0 <1.3.0; ~1.2.3 => >=1.2.3 <1.3.0
+ high = target.next_minor()
+ return [cls.range(Range.OP_GTE, target), cls.range(Range.OP_LT, high)]
+
+ elif prefix == cls.PREFIX_EQ:
+ if major is None:
+ return [cls.range(Range.OP_GTE, target)]
+ elif minor is None:
+ return [cls.range(Range.OP_GTE, target), cls.range(Range.OP_LT, target.next_major())]
+ elif patch is None:
+ return [cls.range(Range.OP_GTE, target), cls.range(Range.OP_LT, target.next_minor())]
+ else:
+ return [cls.range(Range.OP_EQ, target)]
+
+ elif prefix == cls.PREFIX_GT:
+ assert major is not None
+ if minor is None: # >1.x
+ return [cls.range(Range.OP_GTE, target.next_major())]
+ elif patch is None: # >1.2.x => >=1.3.0
+ return [cls.range(Range.OP_GTE, target.next_minor())]
+ else:
+ return [cls.range(Range.OP_GT, target)]
+
+ elif prefix == cls.PREFIX_GTE:
+ return [cls.range(Range.OP_GTE, target)]
+
+ elif prefix == cls.PREFIX_LT:
+ assert major is not None
+ return [cls.range(Range.OP_LT, target)]
+
+ else:
+ assert prefix == cls.PREFIX_LTE
+ assert major is not None
+ if minor is None: # <=1.x => <2.0.0
+ return [cls.range(Range.OP_LT, target.next_major())]
+ elif patch is None: # <=1.2.x => <1.3.0
+ return [cls.range(Range.OP_LT, target.next_minor())]
+ else:
+ return [cls.range(Range.OP_LTE, target)]
diff --git a/third_party/semantic_version/update-info.log b/third_party/semantic_version/update-info.log
new file mode 100644
index 000000000..6d6b80668
--- /dev/null
+++ b/third_party/semantic_version/update-info.log
@@ -0,0 +1,2 @@
+ref: 2.10.0
+272a363824b1e09ae4e494ad00092f8782248821