Skip to content
Merged
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
9 changes: 8 additions & 1 deletion .github/workflows/test-workflow.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
test:
name: Test (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
timeout-minutes: 20
timeout-minutes: 45
strategy:
matrix:
python-version: ["3.10", "3.12", "3.14"]
Expand Down Expand Up @@ -56,6 +56,13 @@ jobs:
with:
dotnet-version: '8.0.x'

# ElixirLS requires Elixir and Erlang/OTP
- name: Set up Elixir
uses: erlef/setup-beam@v1
with:
elixir-version: '1.18'
otp-version: '27'

# --- Workarounds for GitHub runner environment issues ---
# JDTLS downloads its own JRE, but pre-installed JDKs on the runner
# with broken permissions cause JDTLS initialization to fail
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -409,4 +409,5 @@ src/multilspy/language_servers/clangd_language_server/static/
.venv/
venv/

src/multilspy/language_servers/intelephense/static/
src/multilspy/language_servers/intelephense/static/
src/multilspy/language_servers/elixir_language_server/static/
7 changes: 7 additions & 0 deletions src/multilspy/language_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,10 @@ def create(cls, config: MultilspyConfig, logger: MultilspyLogger, repository_roo
from multilspy.language_servers.intelephense.intelephense import Intelephense

return Intelephense(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)
Expand Down Expand Up @@ -444,6 +448,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
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
"""
Provides Elixir specific instantiation of the LanguageServer class using ElixirLS.
"""

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_config import MultilspyConfig
from multilspy.multilspy_logger import MultilspyLogger
from multilspy.multilspy_settings import MultilspySettings
from multilspy.multilspy_utils import FileUtils, PlatformUtils


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

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

def setup_runtime_dependencies(self, logger: MultilspyLogger, config: MultilspyConfig) -> str:
import subprocess

# Verify Elixir is installed
if not shutil.which("elixir"):
raise RuntimeError(
"Elixir is not installed. ElixirLS requires Elixir and Erlang/OTP.\n"
"Install from https://elixir-lang.org/install.html\n"
"Then run: mix local.hex --force"
)

# Ensure hex is installed non-interactively (ElixirLS needs it to build on first launch)
subprocess.run(["mix", "local.hex", "--force"], capture_output=True)

if config.server_binary:
assert os.path.exists(config.server_binary), f"Server binary not found: {config.server_binary}"
return [config.server_binary]

# Try to find elixir-ls or language_server.sh in PATH
for name in ("elixir-ls", "language_server.sh"):
path = shutil.which(name)
if path:
logger.log(f"Found {name} in PATH: {path}", logging.INFO)
return [path]

logger.log(
"NOTE: ElixirLS builds itself from source on first launch. "
"This may take several minutes while it downloads dependencies and compiles.",
logging.WARNING,
)

# Fall back to downloading 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 = config.server_install_dir or MultilspySettings.get_server_install_directory("elixir-ls")
elixir_executable_path = os.path.join(elixir_ls_dir, dependency["binaryName"])

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

assert os.path.exists(elixir_executable_path), f"ElixirLS executable not found at {elixir_executable_path}"

if not dependency["binaryName"].endswith(".bat"):
os.chmod(elixir_executable_path, stat.S_IEXEC | stat.S_IREAD | stat.S_IWRITE)

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"]:
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", 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_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(f"Starting ElixirLS server process with cmd: {self.server.process_launch_info.cmd}", logging.INFO)
await self.server.start()

# Check if the process started successfully
if self.server.process.returncode is not None:
self.logger.log(f"ElixirLS process exited immediately with code {self.server.process.returncode}", logging.ERROR)
raise RuntimeError(f"ElixirLS failed to start (exit code {self.server.process.returncode})")

self.logger.log(f"ElixirLS process started with PID {self.server.process.pid}", logging.INFO)
initialize_params = self._get_initialize_params(self.repository_root_path)

# ElixirLS builds itself from source on first launch (downloads deps,
# compiles). This can take several minutes. 600s timeout accommodates this.
init_response = await asyncio.wait_for(
self.server.send.initialize(initialize_params),
timeout=600,
)
self.logger.log(f"Received initialize response from ElixirLS: {init_response}", logging.INFO)

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

try:
yield self
finally:
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": "Runtime dependencies for ElixirLS language server, downloaded from https://github.com/elixir-lsp/elixir-ls/releases",
"runtimeDependencies": [
{
"id": "elixir-ls",
"platformId": "osx-arm64",
"url": "https://github.com/elixir-lsp/elixir-ls/releases/download/v0.30.0/elixir-ls-v0.30.0.zip",
"archiveType": "zip",
"binaryName": "language_server.sh"
},
{
"id": "elixir-ls",
"platformId": "osx-x64",
"url": "https://github.com/elixir-lsp/elixir-ls/releases/download/v0.30.0/elixir-ls-v0.30.0.zip",
"archiveType": "zip",
"binaryName": "language_server.sh"
},
{
"id": "elixir-ls",
"platformId": "linux-x64",
"url": "https://github.com/elixir-lsp/elixir-ls/releases/download/v0.30.0/elixir-ls-v0.30.0.zip",
"archiveType": "zip",
"binaryName": "language_server.sh"
},
{
"id": "elixir-ls",
"platformId": "linux-arm64",
"url": "https://github.com/elixir-lsp/elixir-ls/releases/download/v0.30.0/elixir-ls-v0.30.0.zip",
"archiveType": "zip",
"binaryName": "language_server.sh"
},
{
"id": "elixir-ls",
"platformId": "win-x64",
"url": "https://github.com/elixir-lsp/elixir-ls/releases/download/v0.30.0/elixir-ls-v0.30.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 @@ -23,6 +23,7 @@ class Language(str, Enum):
DART = "dart"
CPP = "cpp"
PHP = "php"
ELIXIR = "elixir"

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