diff --git a/.github/workflows/test-workflow.yaml b/.github/workflows/test-workflow.yaml index 891b9fd..bda9a25 100644 --- a/.github/workflows/test-workflow.yaml +++ b/.github/workflows/test-workflow.yaml @@ -19,6 +19,10 @@ jobs: go-version: '1.21' - name: Install gopls run: go install golang.org/x/tools/gopls@latest + - name: Install ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.4' - name: Set up Python uses: actions/setup-python@v5 with: diff --git a/src/multilspy/language_server.py b/src/multilspy/language_server.py index 5093526..665d39c 100644 --- a/src/multilspy/language_server.py +++ b/src/multilspy/language_server.py @@ -104,6 +104,10 @@ def create(cls, config: MultilspyConfig, logger: MultilspyLogger, repository_roo from multilspy.language_servers.gopls.gopls import Gopls return Gopls(config, logger, repository_root_path) + elif config.code_language == Language.RUBY: + from multilspy.language_servers.solargraph.solargraph import Solargraph + + return Solargraph(config, logger, repository_root_path) else: logger.log(f"Language {config.code_language} is not supported", logging.ERROR) raise MultilspyException(f"Language {config.code_language} is not supported") diff --git a/src/multilspy/language_servers/solargraph/initialize_params.json b/src/multilspy/language_servers/solargraph/initialize_params.json new file mode 100644 index 0000000..2cbb41f --- /dev/null +++ b/src/multilspy/language_servers/solargraph/initialize_params.json @@ -0,0 +1,15 @@ +{ + "_description": "This file contains the initialization parameters for the Solargraph Language Server.", + "processId": "$processId", + "rootPath": "$rootPath", + "rootUri": "$rootUri", + "capabilities": { + }, + "trace": "verbose", + "workspaceFolders": [ + { + "uri": "$uri", + "name": "$name" + } + ] +} diff --git a/src/multilspy/language_servers/solargraph/runtime_dependencies.json b/src/multilspy/language_servers/solargraph/runtime_dependencies.json new file mode 100644 index 0000000..38cc237 --- /dev/null +++ b/src/multilspy/language_servers/solargraph/runtime_dependencies.json @@ -0,0 +1,11 @@ +{ + "_description": "This file contains URLs and other metadata required for downloading and installing the Solargraph language server.", + "runtimeDependencies": [ + { + "url": "https://rubygems.org/downloads/solargraph-0.51.1.gem", + "installCommand": "gem install solargraph -v 0.51.1", + "binaryName": "solargraph", + "archiveType": "gem" + } + ] +} diff --git a/src/multilspy/language_servers/solargraph/solargraph.py b/src/multilspy/language_servers/solargraph/solargraph.py new file mode 100644 index 0000000..a0a62b8 --- /dev/null +++ b/src/multilspy/language_servers/solargraph/solargraph.py @@ -0,0 +1,193 @@ +""" +Provides Ruby specific instantiation of the LanguageServer class using Solargraph. +Contains various configurations and settings specific to Ruby. +""" + +import asyncio +import json +import logging +import os +import stat +import subprocess +import pathlib +from contextlib import asynccontextmanager +from typing import AsyncIterator + +from multilspy.multilspy_logger import MultilspyLogger +from multilspy.language_server import LanguageServer +from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo +from multilspy.lsp_protocol_handler.lsp_types import InitializeParams +from multilspy.multilspy_config import MultilspyConfig +from multilspy.multilspy_utils import FileUtils +from multilspy.multilspy_utils import PlatformUtils, PlatformId + + +class Solargraph(LanguageServer): + """ + Provides Ruby specific instantiation of the LanguageServer class using Solargraph. + Contains various configurations and settings specific to Ruby. + """ + + def __init__(self, config: MultilspyConfig, logger: MultilspyLogger, repository_root_path: str): + """ + Creates a Solargraph instance. This class is not meant to be instantiated directly. + Use LanguageServer.create() instead. + """ + solargraph_executable_path = self.setup_runtime_dependencies(logger, config, repository_root_path) + super().__init__( + config, + logger, + repository_root_path, + ProcessLaunchInfo(cmd=f"{solargraph_executable_path} stdio", cwd=repository_root_path), + "ruby", + ) + self.server_ready = asyncio.Event() + + def setup_runtime_dependencies(self, logger: MultilspyLogger, config: MultilspyConfig, repository_root_path: str) -> str: + """ + Setup runtime dependencies for Solargraph. + """ + platform_id = PlatformUtils.get_platform_id() + which_cmd = "which" + if platform_id in [PlatformId.WIN_x64, PlatformId.WIN_arm64, PlatformId.WIN_x86]: + which_cmd = "where" + + with open(os.path.join(os.path.dirname(__file__), "runtime_dependencies.json"), "r") as f: + d = json.load(f) + del d["_description"] + + dependency = d["runtimeDependencies"][0] + + # Check if Ruby is installed + try: + result = subprocess.run(["ruby", "--version"], check=True, capture_output=True, cwd=repository_root_path) + ruby_version = result.stdout.strip() + logger.log(f"Ruby version: {ruby_version}", logging.INFO) + except subprocess.CalledProcessError as e: + raise RuntimeError(f"Error checking for Ruby installation: {e.stderr}") + except FileNotFoundError: + raise RuntimeError("Ruby is not installed. Please install Ruby before continuing.") + + # Check if solargraph is installed + try: + result = subprocess.run(["gem", "list", "^solargraph$", "-i"], check=False, capture_output=True, text=True, cwd=repository_root_path) + if result.stdout.strip() == "false": + logger.log("Installing Solargraph...", logging.INFO) + subprocess.run(dependency["installCommand"].split(), check=True, capture_output=True, cwd=repository_root_path) + except subprocess.CalledProcessError as e: + raise RuntimeError(f"Failed to check or install Solargraph. {e.stderr}") + + # Get the solargraph executable path + try: + result = subprocess.run([which_cmd, "solargraph"], check=True, capture_output=True, text=True, cwd=repository_root_path) + executeable_path = result.stdout.strip() + + if not os.path.exists(executeable_path): + raise RuntimeError(f"Solargraph executable not found at {executeable_path}") + + # Ensure the executable has the right permissions + os.chmod(executeable_path, os.stat(executeable_path).st_mode | stat.S_IEXEC) + + return executeable_path + except subprocess.CalledProcessError: + raise RuntimeError("Failed to locate Solargraph executable.") + + def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize params for the Solargraph Language Server. + """ + with open(os.path.join(os.path.dirname(__file__), "initialize_params.json"), "r") as f: + d = json.load(f) + + del d["_description"] + + d["processId"] = os.getpid() + assert d["rootPath"] == "$rootPath" + d["rootPath"] = repository_absolute_path + + assert d["rootUri"] == "$rootUri" + d["rootUri"] = pathlib.Path(repository_absolute_path).as_uri() + + assert d["workspaceFolders"][0]["uri"] == "$uri" + d["workspaceFolders"][0]["uri"] = pathlib.Path(repository_absolute_path).as_uri() + + assert d["workspaceFolders"][0]["name"] == "$name" + d["workspaceFolders"][0]["name"] = os.path.basename(repository_absolute_path) + + return d + + @asynccontextmanager + async def start_server(self) -> AsyncIterator["Solargraph"]: + """ + Starts the Solargraph Language Server for Ruby, waits for the server to be ready and yields the LanguageServer instance. + + Usage: + ``` + async with lsp.start_server(): + # LanguageServer has been initialized and ready to serve requests + await lsp.request_definition(...) + await lsp.request_references(...) + # Shutdown the LanguageServer on exit from scope + # LanguageServer has been shutdown + """ + + async def register_capability_handler(params): + assert "registrations" in params + for registration in params["registrations"]: + if registration["method"] == "workspace/executeCommand": + self.initialize_searcher_command_available.set() + self.resolve_main_method_available.set() + return + + async def lang_status_handler(params): + # TODO: Should we wait for + # server -> client: {'jsonrpc': '2.0', 'method': 'language/status', 'params': {'type': 'ProjectStatus', 'message': 'OK'}} + # Before proceeding? + if params["type"] == "ServiceReady" and params["message"] == "ServiceReady": + self.service_ready_event.set() + + async def execute_client_command_handler(params): + return [] + + async def do_nothing(params): + return + + async def window_log_message(msg): + self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("language/status", lang_status_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_notification("language/actionableNotification", do_nothing) + + async with super().start_server(): + self.logger.log("Starting solargraph server process", logging.INFO) + await self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + self.logger.log( + "Sending initialize request from LSP client to LSP server and awaiting response", + logging.INFO, + ) + self.logger.log(f"Sending init params: {json.dumps(initialize_params, indent=4)}", logging.INFO) + init_response = await self.server.send.initialize(initialize_params) + self.logger.log(f"Received init response: {init_response}", logging.INFO) + assert init_response["capabilities"]["textDocumentSync"] == 2 + assert "completionProvider" in init_response["capabilities"] + assert init_response["capabilities"]["completionProvider"] == { + "resolveProvider": True, + "triggerCharacters": [".", ":", "@"], + } + self.server.notify.initialized({}) + self.completions_available.set() + + self.server_ready.set() + await self.server_ready.wait() + + yield self + + await self.server.shutdown() + await self.server.stop() diff --git a/src/multilspy/multilspy_config.py b/src/multilspy/multilspy_config.py index 221389d..3628e9c 100644 --- a/src/multilspy/multilspy_config.py +++ b/src/multilspy/multilspy_config.py @@ -17,6 +17,7 @@ class Language(str, Enum): TYPESCRIPT = "typescript" JAVASCRIPT = "javascript" GO = "go" + RUBY = "ruby" def __str__(self) -> str: return self.value diff --git a/tests/multilspy/test_multilspy_ruby.py b/tests/multilspy/test_multilspy_ruby.py new file mode 100644 index 0000000..201e3ac --- /dev/null +++ b/tests/multilspy/test_multilspy_ruby.py @@ -0,0 +1,61 @@ +""" +This file contains tests for running the Ruby Language Server: solargraph +""" + +import unittest +import pytest + +from multilspy import LanguageServer +from multilspy.multilspy_config import Language +from multilspy.multilspy_types import Position, CompletionItemKind +from tests.test_utils import create_test_context +from pathlib import PurePath +from tests.multilspy.test_sync_multilspy_ruby import EXPECTED_RESULT + + +@pytest.mark.asyncio +async def test_multilspy_ruby_rubyland(): + """ + Test the working of multilspy with ruby repository - rubyland + """ + code_language = Language.RUBY + params = { + "code_language": code_language, + "repo_url": "https://github.com/jrochkind/rubyland/", + "repo_commit": "c243ee2533a5822f5699a2475e492927ace039c7" + } + with create_test_context(params) as context: + lsp = LanguageServer.create(context.config, context.logger, context.source_directory) + + # All the communication with the language server must be performed inside the context manager + # The server process is started when the context manager is entered and is terminated when the context manager is exited. + # The context manager is an asynchronous context manager, so it must be used with async with. + async with lsp.start_server(): + result = await lsp.request_document_symbols(str(PurePath("app/controllers/application_controller.rb"))) + + assert isinstance(result, tuple) + assert len(result) == 2 + symbol_names = list(map(lambda x: x["name"], result[0])) + assert symbol_names == ['ApplicationController', 'protected_demo_authentication'] + + result = await lsp.request_definition(str(PurePath("app/controllers/feed_controller.rb")), 11, 23) + + feed_path = str(PurePath("app/models/feed.rb")) + assert isinstance(result, list) + assert len(result) == 2 + assert feed_path in list(map(lambda x: x["relativePath"], result)) + + result = await lsp.request_references(feed_path, 0, 7) + + assert isinstance(result, list) + assert len(result) == 8 + + for item in result: + del item["uri"] + del item["absolutePath"] + + case = unittest.TestCase() + case.assertCountEqual( + result, + EXPECTED_RESULT, + ) diff --git a/tests/multilspy/test_sync_multilspy_ruby.py b/tests/multilspy/test_sync_multilspy_ruby.py new file mode 100644 index 0000000..a03ddfa --- /dev/null +++ b/tests/multilspy/test_sync_multilspy_ruby.py @@ -0,0 +1,114 @@ +""" +This file contains tests for running the Ruby Language Server: solargraph +""" + +import unittest + +from multilspy import SyncLanguageServer +from multilspy.multilspy_config import Language +from tests.test_utils import create_test_context +from pathlib import PurePath + +EXPECTED_RESULT = [ + { + "range": { + "start": {"line": 11, "character": 20}, + "end": {"line": 11, "character": 24}, + }, + "relativePath": "app/controllers/feed_controller.rb", + }, + { + "range": { + "start": {"line": 27, "character": 46}, + "end": {"line": 27, "character": 50}, + }, + "relativePath": "app/controllers/feed_controller.rb", + }, + { + "range": { + "start": {"line": 0, "character": 6}, + "end": {"line": 0, "character": 10}, + }, + "relativePath": "app/models/feed.rb", + }, + { + "range": { + "start": {"line": 13, "character": 74}, + "end": {"line": 13, "character": 78}, + }, + "relativePath": "app/updaters/feed_updater.rb", + }, + { + "range": { + "start": {"line": 52, "character": 4}, + "end": {"line": 52, "character": 8}, + }, + "relativePath": "app/updaters/feed_updater.rb", + }, + { + "range": { + "start": {"line": 10, "character": 20}, + "end": {"line": 10, "character": 24}, + }, + "relativePath": "app/updaters/updater.rb", + }, + { + "range": { + "start": {"line": 14, "character": 20}, + "end": {"line": 14, "character": 24}, + }, + "relativePath": "app/updaters/updater.rb", + }, + { + "range": { + "start": {"line": 0, "character": 6}, + "end": {"line": 0, "character": 10}, + }, + "relativePath": "db/migrate/20161029161855_feed.rb", + }, +] + + +def test_multilspy_ruby_rubyland() -> None: + """ + Test the working of multilspy with ruby repository - rubyland + """ + code_language = Language.RUBY + params = { + "code_language": code_language, + "repo_url": "https://github.com/jrochkind/rubyland/", + "repo_commit": "c243ee2533a5822f5699a2475e492927ace039c7" + } + with create_test_context(params) as context: + lsp = SyncLanguageServer.create(context.config, context.logger, context.source_directory) + # All the communication with the language server must be performed inside the context manager + # The server process is started when the context manager is entered and is terminated when the context manager is exited. + with lsp.start_server(): + result = lsp.request_document_symbols(str(PurePath("app/controllers/application_controller.rb"))) + + assert isinstance(result, tuple) + assert len(result) == 2 + symbol_names = list(map(lambda x: x["name"], result[0])) + assert symbol_names == ['ApplicationController', 'protected_demo_authentication'] + + result = lsp.request_definition(str(PurePath("app/controllers/feed_controller.rb")), 11, 23) + + feed_path = str(PurePath("app/models/feed.rb")) + assert isinstance(result, list) + assert len(result) == 2 + assert feed_path in list(map(lambda x: x["relativePath"], result)) + + result = lsp.request_references(feed_path, 0, 7) + + assert isinstance(result, list) + assert len(result) == 8 + + for item in result: + del item["uri"] + del item["absolutePath"] + + case = unittest.TestCase() + case.assertCountEqual( + result, + EXPECTED_RESULT, + )