Skip to content

Commit 65a3aa0

Browse files
Merge pull request #46 from defmethodinc/add-ruby-solargraph
Add ruby support via solargraph
2 parents cf5ccb3 + 232de05 commit 65a3aa0

File tree

8 files changed

+403
-0
lines changed

8 files changed

+403
-0
lines changed

.github/workflows/test-workflow.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ jobs:
1919
go-version: '1.21'
2020
- name: Install gopls
2121
run: go install golang.org/x/tools/gopls@latest
22+
- name: Install ruby
23+
uses: ruby/setup-ruby@v1
24+
with:
25+
ruby-version: '3.4'
2226
- name: Set up Python
2327
uses: actions/setup-python@v5
2428
with:

src/multilspy/language_server.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,10 @@ def create(cls, config: MultilspyConfig, logger: MultilspyLogger, repository_roo
104104
from multilspy.language_servers.gopls.gopls import Gopls
105105

106106
return Gopls(config, logger, repository_root_path)
107+
elif config.code_language == Language.RUBY:
108+
from multilspy.language_servers.solargraph.solargraph import Solargraph
109+
110+
return Solargraph(config, logger, repository_root_path)
107111
else:
108112
logger.log(f"Language {config.code_language} is not supported", logging.ERROR)
109113
raise MultilspyException(f"Language {config.code_language} is not supported")
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"_description": "This file contains the initialization parameters for the Solargraph Language Server.",
3+
"processId": "$processId",
4+
"rootPath": "$rootPath",
5+
"rootUri": "$rootUri",
6+
"capabilities": {
7+
},
8+
"trace": "verbose",
9+
"workspaceFolders": [
10+
{
11+
"uri": "$uri",
12+
"name": "$name"
13+
}
14+
]
15+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"_description": "This file contains URLs and other metadata required for downloading and installing the Solargraph language server.",
3+
"runtimeDependencies": [
4+
{
5+
"url": "https://rubygems.org/downloads/solargraph-0.51.1.gem",
6+
"installCommand": "gem install solargraph -v 0.51.1",
7+
"binaryName": "solargraph",
8+
"archiveType": "gem"
9+
}
10+
]
11+
}
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
"""
2+
Provides Ruby specific instantiation of the LanguageServer class using Solargraph.
3+
Contains various configurations and settings specific to Ruby.
4+
"""
5+
6+
import asyncio
7+
import json
8+
import logging
9+
import os
10+
import stat
11+
import subprocess
12+
import pathlib
13+
from contextlib import asynccontextmanager
14+
from typing import AsyncIterator
15+
16+
from multilspy.multilspy_logger import MultilspyLogger
17+
from multilspy.language_server import LanguageServer
18+
from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo
19+
from multilspy.lsp_protocol_handler.lsp_types import InitializeParams
20+
from multilspy.multilspy_config import MultilspyConfig
21+
from multilspy.multilspy_utils import FileUtils
22+
from multilspy.multilspy_utils import PlatformUtils, PlatformId
23+
24+
25+
class Solargraph(LanguageServer):
26+
"""
27+
Provides Ruby specific instantiation of the LanguageServer class using Solargraph.
28+
Contains various configurations and settings specific to Ruby.
29+
"""
30+
31+
def __init__(self, config: MultilspyConfig, logger: MultilspyLogger, repository_root_path: str):
32+
"""
33+
Creates a Solargraph instance. This class is not meant to be instantiated directly.
34+
Use LanguageServer.create() instead.
35+
"""
36+
solargraph_executable_path = self.setup_runtime_dependencies(logger, config, repository_root_path)
37+
super().__init__(
38+
config,
39+
logger,
40+
repository_root_path,
41+
ProcessLaunchInfo(cmd=f"{solargraph_executable_path} stdio", cwd=repository_root_path),
42+
"ruby",
43+
)
44+
self.server_ready = asyncio.Event()
45+
46+
def setup_runtime_dependencies(self, logger: MultilspyLogger, config: MultilspyConfig, repository_root_path: str) -> str:
47+
"""
48+
Setup runtime dependencies for Solargraph.
49+
"""
50+
platform_id = PlatformUtils.get_platform_id()
51+
which_cmd = "which"
52+
if platform_id in [PlatformId.WIN_x64, PlatformId.WIN_arm64, PlatformId.WIN_x86]:
53+
which_cmd = "where"
54+
55+
with open(os.path.join(os.path.dirname(__file__), "runtime_dependencies.json"), "r") as f:
56+
d = json.load(f)
57+
del d["_description"]
58+
59+
dependency = d["runtimeDependencies"][0]
60+
61+
# Check if Ruby is installed
62+
try:
63+
result = subprocess.run(["ruby", "--version"], check=True, capture_output=True, cwd=repository_root_path)
64+
ruby_version = result.stdout.strip()
65+
logger.log(f"Ruby version: {ruby_version}", logging.INFO)
66+
except subprocess.CalledProcessError as e:
67+
raise RuntimeError(f"Error checking for Ruby installation: {e.stderr}")
68+
except FileNotFoundError:
69+
raise RuntimeError("Ruby is not installed. Please install Ruby before continuing.")
70+
71+
# Check if solargraph is installed
72+
try:
73+
result = subprocess.run(["gem", "list", "^solargraph$", "-i"], check=False, capture_output=True, text=True, cwd=repository_root_path)
74+
if result.stdout.strip() == "false":
75+
logger.log("Installing Solargraph...", logging.INFO)
76+
subprocess.run(dependency["installCommand"].split(), check=True, capture_output=True, cwd=repository_root_path)
77+
except subprocess.CalledProcessError as e:
78+
raise RuntimeError(f"Failed to check or install Solargraph. {e.stderr}")
79+
80+
# Get the solargraph executable path
81+
try:
82+
result = subprocess.run([which_cmd, "solargraph"], check=True, capture_output=True, text=True, cwd=repository_root_path)
83+
executeable_path = result.stdout.strip()
84+
85+
if not os.path.exists(executeable_path):
86+
raise RuntimeError(f"Solargraph executable not found at {executeable_path}")
87+
88+
# Ensure the executable has the right permissions
89+
os.chmod(executeable_path, os.stat(executeable_path).st_mode | stat.S_IEXEC)
90+
91+
return executeable_path
92+
except subprocess.CalledProcessError:
93+
raise RuntimeError("Failed to locate Solargraph executable.")
94+
95+
def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams:
96+
"""
97+
Returns the initialize params for the Solargraph Language Server.
98+
"""
99+
with open(os.path.join(os.path.dirname(__file__), "initialize_params.json"), "r") as f:
100+
d = json.load(f)
101+
102+
del d["_description"]
103+
104+
d["processId"] = os.getpid()
105+
assert d["rootPath"] == "$rootPath"
106+
d["rootPath"] = repository_absolute_path
107+
108+
assert d["rootUri"] == "$rootUri"
109+
d["rootUri"] = pathlib.Path(repository_absolute_path).as_uri()
110+
111+
assert d["workspaceFolders"][0]["uri"] == "$uri"
112+
d["workspaceFolders"][0]["uri"] = pathlib.Path(repository_absolute_path).as_uri()
113+
114+
assert d["workspaceFolders"][0]["name"] == "$name"
115+
d["workspaceFolders"][0]["name"] = os.path.basename(repository_absolute_path)
116+
117+
return d
118+
119+
@asynccontextmanager
120+
async def start_server(self) -> AsyncIterator["Solargraph"]:
121+
"""
122+
Starts the Solargraph Language Server for Ruby, waits for the server to be ready and yields the LanguageServer instance.
123+
124+
Usage:
125+
```
126+
async with lsp.start_server():
127+
# LanguageServer has been initialized and ready to serve requests
128+
await lsp.request_definition(...)
129+
await lsp.request_references(...)
130+
# Shutdown the LanguageServer on exit from scope
131+
# LanguageServer has been shutdown
132+
"""
133+
134+
async def register_capability_handler(params):
135+
assert "registrations" in params
136+
for registration in params["registrations"]:
137+
if registration["method"] == "workspace/executeCommand":
138+
self.initialize_searcher_command_available.set()
139+
self.resolve_main_method_available.set()
140+
return
141+
142+
async def lang_status_handler(params):
143+
# TODO: Should we wait for
144+
# server -> client: {'jsonrpc': '2.0', 'method': 'language/status', 'params': {'type': 'ProjectStatus', 'message': 'OK'}}
145+
# Before proceeding?
146+
if params["type"] == "ServiceReady" and params["message"] == "ServiceReady":
147+
self.service_ready_event.set()
148+
149+
async def execute_client_command_handler(params):
150+
return []
151+
152+
async def do_nothing(params):
153+
return
154+
155+
async def window_log_message(msg):
156+
self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO)
157+
158+
self.server.on_request("client/registerCapability", register_capability_handler)
159+
self.server.on_notification("language/status", lang_status_handler)
160+
self.server.on_notification("window/logMessage", window_log_message)
161+
self.server.on_request("workspace/executeClientCommand", execute_client_command_handler)
162+
self.server.on_notification("$/progress", do_nothing)
163+
self.server.on_notification("textDocument/publishDiagnostics", do_nothing)
164+
self.server.on_notification("language/actionableNotification", do_nothing)
165+
166+
async with super().start_server():
167+
self.logger.log("Starting solargraph server process", logging.INFO)
168+
await self.server.start()
169+
initialize_params = self._get_initialize_params(self.repository_root_path)
170+
171+
self.logger.log(
172+
"Sending initialize request from LSP client to LSP server and awaiting response",
173+
logging.INFO,
174+
)
175+
self.logger.log(f"Sending init params: {json.dumps(initialize_params, indent=4)}", logging.INFO)
176+
init_response = await self.server.send.initialize(initialize_params)
177+
self.logger.log(f"Received init response: {init_response}", logging.INFO)
178+
assert init_response["capabilities"]["textDocumentSync"] == 2
179+
assert "completionProvider" in init_response["capabilities"]
180+
assert init_response["capabilities"]["completionProvider"] == {
181+
"resolveProvider": True,
182+
"triggerCharacters": [".", ":", "@"],
183+
}
184+
self.server.notify.initialized({})
185+
self.completions_available.set()
186+
187+
self.server_ready.set()
188+
await self.server_ready.wait()
189+
190+
yield self
191+
192+
await self.server.shutdown()
193+
await self.server.stop()

src/multilspy/multilspy_config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ class Language(str, Enum):
1717
TYPESCRIPT = "typescript"
1818
JAVASCRIPT = "javascript"
1919
GO = "go"
20+
RUBY = "ruby"
2021

2122
def __str__(self) -> str:
2223
return self.value
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""
2+
This file contains tests for running the Ruby Language Server: solargraph
3+
"""
4+
5+
import unittest
6+
import pytest
7+
8+
from multilspy import LanguageServer
9+
from multilspy.multilspy_config import Language
10+
from multilspy.multilspy_types import Position, CompletionItemKind
11+
from tests.test_utils import create_test_context
12+
from pathlib import PurePath
13+
from tests.multilspy.test_sync_multilspy_ruby import EXPECTED_RESULT
14+
15+
16+
@pytest.mark.asyncio
17+
async def test_multilspy_ruby_rubyland():
18+
"""
19+
Test the working of multilspy with ruby repository - rubyland
20+
"""
21+
code_language = Language.RUBY
22+
params = {
23+
"code_language": code_language,
24+
"repo_url": "https://github.com/jrochkind/rubyland/",
25+
"repo_commit": "c243ee2533a5822f5699a2475e492927ace039c7"
26+
}
27+
with create_test_context(params) as context:
28+
lsp = LanguageServer.create(context.config, context.logger, context.source_directory)
29+
30+
# All the communication with the language server must be performed inside the context manager
31+
# The server process is started when the context manager is entered and is terminated when the context manager is exited.
32+
# The context manager is an asynchronous context manager, so it must be used with async with.
33+
async with lsp.start_server():
34+
result = await lsp.request_document_symbols(str(PurePath("app/controllers/application_controller.rb")))
35+
36+
assert isinstance(result, tuple)
37+
assert len(result) == 2
38+
symbol_names = list(map(lambda x: x["name"], result[0]))
39+
assert symbol_names == ['ApplicationController', 'protected_demo_authentication']
40+
41+
result = await lsp.request_definition(str(PurePath("app/controllers/feed_controller.rb")), 11, 23)
42+
43+
feed_path = str(PurePath("app/models/feed.rb"))
44+
assert isinstance(result, list)
45+
assert len(result) == 2
46+
assert feed_path in list(map(lambda x: x["relativePath"], result))
47+
48+
result = await lsp.request_references(feed_path, 0, 7)
49+
50+
assert isinstance(result, list)
51+
assert len(result) == 8
52+
53+
for item in result:
54+
del item["uri"]
55+
del item["absolutePath"]
56+
57+
case = unittest.TestCase()
58+
case.assertCountEqual(
59+
result,
60+
EXPECTED_RESULT,
61+
)

0 commit comments

Comments
 (0)