Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ruby support via solargraph #46

Merged
merged 21 commits into from
Jan 29, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f6dbab9
Initial setup for adding ruby support using solargraph
sbrudz Jan 17, 2025
c4deebb
Skip sending client capabilities otherwise solargraph responds with a…
sbrudz Jan 22, 2025
29c6137
Run solargraph-related commands in repo directory
sbrudz Jan 22, 2025
87f27e1
Fix sync tests to work with rubyland repo
sbrudz Jan 22, 2025
8b1a863
Use solargraph in comments
sbrudz Jan 22, 2025
6a39cad
Fix async ruby tests
sbrudz Jan 22, 2025
635f032
Check for correct version of ruby
sbrudz Jan 22, 2025
2f2bdbc
Add support for windows platforms
sbrudz Jan 22, 2025
1b934d3
Make solargraph work gracefully across ruby versions
sbrudz Jan 27, 2025
f30a3d2
Improve logging for when solargraph install fails
sbrudz Jan 27, 2025
a9091c2
Temporarily trigger tests on all pushes
sbrudz Jan 27, 2025
5db5d7d
Correctly handle solargraph not installed case
sbrudz Jan 27, 2025
9ce8760
Capture the output of solargraph install failures
sbrudz Jan 27, 2025
e74f8ba
Install ruby to address permissions issues in github actions
sbrudz Jan 27, 2025
d296c21
Run the solargraph install in the repo directory
sbrudz Jan 27, 2025
53e2ee8
Add tests for document symbols
sbrudz Jan 27, 2025
eb5205b
Make tests resilient to ordering changes
sbrudz Jan 27, 2025
7869543
Add tmate to debug on the runner
sbrudz Jan 27, 2025
6ac4ae1
Upgrade version of solargraph being used
sbrudz Jan 27, 2025
06554c6
Revert "Add tmate to debug on the runner"
sbrudz Jan 27, 2025
232de05
Revert "Temporarily trigger tests on all pushes"
sbrudz Jan 27, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/test-workflow.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions src/multilspy/language_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
15 changes: 15 additions & 0 deletions src/multilspy/language_servers/solargraph/initialize_params.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
193 changes: 193 additions & 0 deletions src/multilspy/language_servers/solargraph/solargraph.py
Original file line number Diff line number Diff line change
@@ -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()
1 change: 1 addition & 0 deletions src/multilspy/multilspy_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class Language(str, Enum):
TYPESCRIPT = "typescript"
JAVASCRIPT = "javascript"
GO = "go"
RUBY = "ruby"

def __str__(self) -> str:
return self.value
Expand Down
61 changes: 61 additions & 0 deletions tests/multilspy/test_multilspy_ruby.py
Original file line number Diff line number Diff line change
@@ -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,
)
Loading
Loading