Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
32 changes: 20 additions & 12 deletions src/multilspy/language_server.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""
This file contains the main interface and the public API for multilspy.
The abstract class LanguageServer provides a factory method, creator that is
This file contains the main interface and the public API for multilspy.
The abstract class LanguageServer provides a factory method, creator that is
intended for creating instantiations of language specific clients.
The details of Language Specific configuration are not exposed to the user.
"""
Expand Down Expand Up @@ -122,6 +122,9 @@ def create(cls, config: MultilspyConfig, logger: MultilspyLogger, repository_roo
from multilspy.language_servers.clangd_language_server.clangd_language_server import ClangdLanguageServer

return ClangdLanguageServer(config, logger, repository_root_path)
elif config.code_language == Language.ELIXIR:
from multilspy.language_servers.elixir_language_server.elixir_language_server import ElixirLanguageServer
return ElixirLanguageServer(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 Expand Up @@ -256,7 +259,7 @@ def insert_text_at_position(
self, relative_file_path: str, line: int, column: int, text_to_be_inserted: str
) -> multilspy_types.Position:
"""
Insert text at the given line and column in the given file and return
Insert text at the given line and column in the given file and return
the updated cursor position after inserting the text.

:param relative_file_path: The relative path of the file to open.
Expand Down Expand Up @@ -401,6 +404,8 @@ async def request_definition(
}
)



ret: List[multilspy_types.Location] = []
if isinstance(response, list):
# response is either of type Location[] or LocationLink[]
Expand Down Expand Up @@ -436,6 +441,9 @@ async def request_definition(
new_item["absolutePath"] = PathUtils.uri_to_path(new_item["uri"])
new_item["relativePath"] = PathUtils.get_relative_path(new_item["absolutePath"], self.repository_root_path)
ret.append(multilspy_types.Location(**new_item))
elif response is None:
# LSP spec allows null response when no definition is found
pass
else:
assert False, f"Unexpected response from Language Server: {response}"

Expand Down Expand Up @@ -543,7 +551,7 @@ async def request_completions(
completion_item = {}
if "detail" in item:
completion_item["detail"] = item["detail"]

if "label" in item:
completion_item["completionText"] = item["label"]
completion_item["kind"] = item["kind"]
Expand All @@ -567,7 +575,7 @@ async def request_completions(
== item["textEdit"]["range"]["end"]["character"],
)
)

completion_item["completionText"] = item["textEdit"]["newText"]
completion_item["kind"] = item["kind"]
elif "textEdit" in item and "insert" in item["textEdit"]:
Expand Down Expand Up @@ -600,7 +608,7 @@ async def request_document_symbols(self, relative_file_path: str) -> Tuple[List[
}
}
)

ret: List[multilspy_types.UnifiedSymbolInformation] = []
l_tree = None
assert isinstance(response, list), f"Unexpected response from Language Server: {response}"
Expand All @@ -611,7 +619,7 @@ async def request_document_symbols(self, relative_file_path: str) -> Tuple[List[

if LSPConstants.CHILDREN in item:
# TODO: l_tree should be a list of TreeRepr. Define the following function to return TreeRepr as well

def visit_tree_nodes_and_build_tree_repr(tree: LSPTypes.DocumentSymbol) -> List[multilspy_types.UnifiedSymbolInformation]:
l: List[multilspy_types.UnifiedSymbolInformation] = []
children = tree['children'] if 'children' in tree else []
Expand All @@ -621,13 +629,13 @@ def visit_tree_nodes_and_build_tree_repr(tree: LSPTypes.DocumentSymbol) -> List[
for child in children:
l.extend(visit_tree_nodes_and_build_tree_repr(child))
return l

ret.extend(visit_tree_nodes_and_build_tree_repr(item))
else:
ret.append(multilspy_types.UnifiedSymbolInformation(**item))

return ret, l_tree

async def request_hover(self, relative_file_path: str, line: int, column: int) -> Union[multilspy_types.Hover, None]:
"""
Raise a [textDocument/hover](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_hover) request to the Language Server
Expand All @@ -651,7 +659,7 @@ async def request_hover(self, relative_file_path: str, line: int, column: int) -
},
}
)

if response is None:
return None

Expand Down Expand Up @@ -730,7 +738,7 @@ def insert_text_at_position(
self, relative_file_path: str, line: int, column: int, text_to_be_inserted: str
) -> multilspy_types.Position:
"""
Insert text at the given line and column in the given file and return
Insert text at the given line and column in the given file and return
the updated cursor position after inserting the text.

:param relative_file_path: The relative path of the file to open.
Expand Down Expand Up @@ -758,7 +766,7 @@ def get_open_file_text(self, relative_file_path: str) -> str:
:param relative_file_path: The relative path of the file to open.
"""
return self.language_server.get_open_file_text(relative_file_path)

@contextmanager
def start_server(self) -> Iterator["SyncLanguageServer"]:
"""
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import asyncio
from contextlib import asynccontextmanager
import logging
import os
import pathlib
import shutil
import stat
import json
from typing import AsyncIterator

from multilspy.language_server import LanguageServer
from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo
from multilspy.multilspy_logger import MultilspyLogger
from multilspy.multilspy_utils import FileUtils, PlatformUtils

class ElixirLanguageServer(LanguageServer):
"""
Provides Elixir-specific instantiation of the LanguageServer class.
"""

def __init__(self, config, logger, repository_root_path):
executable_path = self.setup_runtime_dependencies(logger)
super().__init__(
config,
logger,
repository_root_path,
ProcessLaunchInfo(cmd=executable_path, cwd=repository_root_path),
"elixir",
)

def setup_runtime_dependencies(self, logger: MultilspyLogger) -> str:
# First try to find elixir-ls in PATH
path = shutil.which("elixir-ls")
if path:
logger.log(f"Found elixir-ls in PATH: {path}", logging.INFO)
return path

# Try language_server.sh directly (if user has ElixirLS installed)
language_server_path = shutil.which("language_server.sh")
if language_server_path:
logger.log(f"Found language_server.sh in PATH: {language_server_path}", logging.INFO)
return language_server_path

# Fall back to downloading and setting up ElixirLS
platform_id = PlatformUtils.get_platform_id()
with open(os.path.join(os.path.dirname(__file__), "runtime_dependencies.json"), "r") as f:
d = json.load(f)
del d["_description"]

runtime_dependencies = [
dep for dep in d["runtimeDependencies"] if dep["platformId"] == platform_id.value
]

if not runtime_dependencies:
raise RuntimeError(f"No runtime dependency found for platform {platform_id.value}")

dependency = runtime_dependencies[0]
elixir_ls_dir = os.path.join(os.path.dirname(__file__), "static", "elixir-ls")
elixir_executable_path = os.path.join(elixir_ls_dir, dependency["binaryName"])

if not os.path.exists(elixir_ls_dir):
os.makedirs(elixir_ls_dir)
logger.log(f"Downloading ElixirLS from {dependency['url']}", logging.INFO)
FileUtils.download_and_extract_archive(
logger, dependency["url"], elixir_ls_dir, dependency["archiveType"]
)

if not os.path.exists(elixir_executable_path):
raise RuntimeError(f"ElixirLS executable not found at {elixir_executable_path}")

# Make executable (important for Unix-like systems)
if not dependency["binaryName"].endswith(".bat"):
os.chmod(elixir_executable_path, stat.S_IEXEC | stat.S_IREAD | stat.S_IWRITE)

logger.log(f"Using ElixirLS executable: {elixir_executable_path}", logging.INFO)
return elixir_executable_path



def _get_initialize_params(self, repository_absolute_path: str):
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()
d["rootPath"] = repository_absolute_path
d["rootUri"] = pathlib.Path(repository_absolute_path).as_uri()
d["workspaceFolders"][0]["uri"] = pathlib.Path(repository_absolute_path).as_uri()
d["workspaceFolders"][0]["name"] = os.path.basename(repository_absolute_path)

return d

@asynccontextmanager
async def start_server(self) -> AsyncIterator["ElixirLanguageServer"]:
# Set up ElixirLS-specific message handlers
async def execute_client_command_handler(params):
self.logger.log(f"executeClientCommand: {params}", logging.DEBUG)
return []

async def do_nothing(params):
self.logger.log(f"Received notification: {params}", logging.DEBUG)
return

async def check_experimental_status(params):
self.logger.log(f"experimental/serverStatus: {params}", logging.DEBUG)
pass

async def window_log_message(msg):
self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO)

async def window_show_message(msg):
self.logger.log(f"LSP: window/showMessage: {msg}", logging.INFO)

# Register handlers for ElixirLS-specific notifications and requests
self.server.on_request("client/registerCapability", do_nothing)
self.server.on_notification("language/status", do_nothing)
self.server.on_notification("window/logMessage", window_log_message)
self.server.on_notification("window/showMessage", window_show_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)
self.server.on_notification("experimental/serverStatus", check_experimental_status)

async with super().start_server():
self.logger.log("Starting ElixirLS server process", logging.INFO)
await self.server.start()

initialize_params = self._get_initialize_params(self.repository_root_path)
self.logger.log(f"Sending initialize request to ElixirLS: {json.dumps(initialize_params, indent=2)}", logging.DEBUG)

try:
init_response = await asyncio.wait_for(
self.server.send_request("initialize", initialize_params),
timeout=60,
)
self.logger.log(f"Received initialize response: {init_response}", logging.INFO)

# Verify that ElixirLS supports the capabilities we need
capabilities = init_response.get("capabilities", {})
if not capabilities.get("hoverProvider"):
self.logger.log("Warning: ElixirLS does not support hover", logging.WARNING)
if not capabilities.get("definitionProvider"):
self.logger.log("Warning: ElixirLS does not support go-to-definition", logging.WARNING)
if not capabilities.get("completionProvider"):
self.logger.log("Warning: ElixirLS does not support completions", logging.WARNING)

except asyncio.TimeoutError:
self.logger.log("Timed out waiting for initialize response from ElixirLS", logging.ERROR)
raise

self.server.notify.initialized({})
self.completions_available.set()

yield self

# Proper shutdown sequence
self.logger.log("Shutting down ElixirLS server", logging.INFO)
await self.server.shutdown()
await self.server.stop()
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
{
"_description": "This file contains the initialization parameters for the Elixir Language Server.",
"processId": "$processId",
"rootPath": "$rootPath",
"rootUri": "$rootUri",
"capabilities": {
"textDocument": {
"hover": {
"contentFormat": ["markdown", "plaintext"]
},
"completion": {
"completionItem": {
"snippetSupport": true,
"documentationFormat": ["markdown", "plaintext"]
}
},
"definition": {
"linkSupport": true
},
"references": {},
"documentSymbol": {
"hierarchicalDocumentSymbolSupport": true
},
"formatting": {},
"codeAction": {}
},
"workspace": {
"workspaceSymbol": {},
"executeCommand": {},
"configuration": true,
"workspaceFolders": true
}
},
"initializationOptions": {
"dialyzerEnabled": true,
"fetchDeps": true,
"suggestSpecs": true,
"mixEnv": "test",
"mixTarget": "host"
},
"trace": "verbose",
"workspaceFolders": [
{
"uri": "$uri",
"name": "$name"
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"_description": "ElixirLS package - single platform-agnostic release with platform-specific binary names",
"runtimeDependencies": [
{
"id": "elixir-ls",
"platformId": "osx-arm64",
"url": "https://github.com/elixir-lsp/elixir-ls/releases/latest/download/elixir-ls-v0.28.0.zip",
"archiveType": "zip",
"binaryName": "language_server.sh"
},
{
"id": "elixir-ls",
"platformId": "osx-x64",
"url": "https://github.com/elixir-lsp/elixir-ls/releases/latest/download/elixir-ls-v0.28.0.zip",
"archiveType": "zip",
"binaryName": "language_server.sh"
},
{
"id": "elixir-ls",
"platformId": "linux-x64",
"url": "https://github.com/elixir-lsp/elixir-ls/releases/latest/download/elixir-ls-v0.28.0.zip",
"archiveType": "zip",
"binaryName": "language_server.sh"
},
{
"id": "elixir-ls",
"platformId": "linux-arm64",
"url": "https://github.com/elixir-lsp/elixir-ls/releases/latest/download/elixir-ls-v0.28.0.zip",
"archiveType": "zip",
"binaryName": "language_server.sh"
},
{
"id": "elixir-ls",
"platformId": "windows-x64",
"url": "https://github.com/elixir-lsp/elixir-ls/releases/latest/download/elixir-ls-v0.28.0.zip",
"archiveType": "zip",
"binaryName": "language_server.bat"
}
]
}
1 change: 1 addition & 0 deletions src/multilspy/multilspy_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class Language(str, Enum):
RUBY = "ruby"
DART = "dart"
CPP = "cpp"
ELIXIR = "elixir"

def __str__(self) -> str:
return self.value
Expand Down
Loading
Loading