From d8b4394affba806f625f411b499e63d3e2c0f539 Mon Sep 17 00:00:00 2001 From: Guillaume Noailles Date: Sat, 6 Jun 2026 18:18:42 +0200 Subject: [PATCH 1/3] Add ols for Odin language support --- src/multilspy/language_server.py | 4 + .../initialize_params.json | 7 + .../odin_language_server.py | 142 ++++++++++++++++++ src/multilspy/multilspy_config.py | 1 + tests/multilspy/test_multilspy_odin.py | 90 +++++++++++ 5 files changed, 244 insertions(+) create mode 100644 src/multilspy/language_servers/odin_language_server/initialize_params.json create mode 100644 src/multilspy/language_servers/odin_language_server/odin_language_server.py create mode 100644 tests/multilspy/test_multilspy_odin.py diff --git a/src/multilspy/language_server.py b/src/multilspy/language_server.py index 3af094b..22cdeea 100644 --- a/src/multilspy/language_server.py +++ b/src/multilspy/language_server.py @@ -131,6 +131,10 @@ def create(cls, config: MultilspyConfig, logger: MultilspyLogger, repository_roo from multilspy.language_servers.elixir_language_server.elixir_language_server import ElixirLanguageServer return ElixirLanguageServer(config, logger, repository_root_path) + elif config.code_language == Language.ODIN: + from multilspy.language_servers.odin_language_server.odin_language_server import Ols + + return Ols(config, logger, repository_root_path) else: logger.log(f"Language {config.code_language} is not supported", logging.ERROR) diff --git a/src/multilspy/language_servers/odin_language_server/initialize_params.json b/src/multilspy/language_servers/odin_language_server/initialize_params.json new file mode 100644 index 0000000..e5f1525 --- /dev/null +++ b/src/multilspy/language_servers/odin_language_server/initialize_params.json @@ -0,0 +1,7 @@ +{ + "_description": "This file contains the initialization parameters for the Odin Language Server.", + "$schema": "https://raw.githubusercontent.com/DanielGavin/ols/master/misc/ols.schema.json", + "enable_document_symbols": true, + "enable_hover": true, + "enable_snippets": true +} diff --git a/src/multilspy/language_servers/odin_language_server/odin_language_server.py b/src/multilspy/language_servers/odin_language_server/odin_language_server.py new file mode 100644 index 0000000..67edc1e --- /dev/null +++ b/src/multilspy/language_servers/odin_language_server/odin_language_server.py @@ -0,0 +1,142 @@ +import asyncio +import json +import logging +import os +import pathlib +import subprocess +from contextlib import asynccontextmanager +from typing import AsyncIterator + +from multilspy.language_server import LanguageServer +from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo +from multilspy.lsp_protocol_handler.lsp_types import InitializeParams + +class Ols(LanguageServer): + """ + Provides Odin-specific instantiation of the LanguageServer class using ols. + """ + + @staticmethod + def _get_odin_version(): + """Get the installed Odin version or None if not found.""" + try: + result = subprocess.run(['odin', 'version'], capture_output=True, text=True) + if result.returncode == 0: + return result.stdout.strip() + except FileNotFoundError: + return None + return None + + @staticmethod + def _get_ols_version(): + """Get the installed ols version or None if not found.""" + try: + result = subprocess.run(['ols', 'version'], capture_output=True, text=True) + if result.returncode == 0: + return result.stdout.strip() + except FileNotFoundError: + return None + return None + + @classmethod + def setup_runtime_dependency(cls): + """ + Check if required Odin runtime dependencies are available. + Raises RuntimeError with helpful message if dependencies are missing. + """ + missing_deps = [] + + # Check for Odin installation + odin_version = cls._get_odin_version() + if not odin_version: + missing_deps.append(("Odin", "https://odin-lang.org/docs/install/")) + + # Check for ols + ols_version = cls._get_ols_version() + if not ols_version: + missing_deps.append(("ols", "https://github.com/DanielGavin/ols#installation")) + + if missing_deps: + error_msg = "Missing required dependencies:\n" + for dep, install_url in missing_deps: + error_msg += f"- {dep}: Please install from {install_url}\n" + raise RuntimeError(error_msg) + + return True + + + def __init__(self, config, logger, repository_root_path): + if config.server_binary: + assert os.path.exists(config.server_binary), f"Server binary not found: {config.server_binary}" + cmd = [config.server_binary] + else: + # Check runtime dependencies before initializing + self.setup_runtime_dependency() + cmd = ["ols"] + + super().__init__( + config, + logger, + repository_root_path, + ProcessLaunchInfo(cmd=cmd, cwd=repository_root_path), + "odin", + ) + self.server_ready = asyncio.Event() + self.request_id = 0 + + + def _get_initialize_params(self) -> InitializeParams: + """ + Returns the initialize params for the Odin Language Server. + """ + with open(os.path.join(os.path.dirname(__file__), "initialize_params.json"), "r") as f: + d = json.load(f) + + del d["_description"] + return d + + + @asynccontextmanager + async def start_server(self) -> AsyncIterator["Ols"]: + """Start ols server process""" + async def register_capability_handler(params): + return + + async def window_log_message(msg): + self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + + async def do_nothing(params): + return + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + async with super().start_server(): + self.logger.log("Starting ols server process", logging.INFO) + await self.server.start() + initialize_params = self._get_initialize_params() + + self.logger.log( + "Sending initialize request from LSP client to LSP server and awaiting response", + logging.INFO, + ) + init_response = await self.server.send.initialize(initialize_params) + + # Verify server capabilities + assert "textDocumentSync" in init_response["capabilities"] + assert "completionProvider" in init_response["capabilities"] + assert "definitionProvider" in init_response["capabilities"] + + self.server.notify.initialized({}) + self.completions_available.set() + + # ols server is typically ready immediately after initialization + self.server_ready.set() + await self.server_ready.wait() + try: + yield self + finally: + await self.server.shutdown() + await self.server.stop() diff --git a/src/multilspy/multilspy_config.py b/src/multilspy/multilspy_config.py index b12a8ca..897ff1a 100644 --- a/src/multilspy/multilspy_config.py +++ b/src/multilspy/multilspy_config.py @@ -24,6 +24,7 @@ class Language(str, Enum): CPP = "cpp" PHP = "php" ELIXIR = "elixir" + ODIN = "odin" def __str__(self) -> str: return self.value diff --git a/tests/multilspy/test_multilspy_odin.py b/tests/multilspy/test_multilspy_odin.py new file mode 100644 index 0000000..d61fd63 --- /dev/null +++ b/tests/multilspy/test_multilspy_odin.py @@ -0,0 +1,90 @@ +""" +This file contains tests for running the Odin Language Server: ols +""" + +import pytest +from multilspy import LanguageServer +from multilspy.multilspy_config import Language +from tests.test_utils import create_test_context +from pathlib import PurePath + +pytest_plugins = ("pytest_asyncio",) + +@pytest.mark.asyncio +async def test_multilspy_odin_example(): + """ + Test the working of multilspy with odin repository - https://github.com/odin-lang/examples + """ + code_language = Language.ODIN + params = { + "code_language": code_language, + "repo_url": "https://github.com/odin-lang/examples/", + "repo_commit": "a72abbdd1c87022188e82e8bc35c359d40cb1b28", + } + with create_test_context(params) as context: + lsp = LanguageServer.create(context.config, context.logger, context.source_directory) + + async with lsp.start_server(): + # Wait for server to be fully initialized + await lsp.server_ready.wait() + + path = str(PurePath("absolute_beginners/5_structs.odin")) + + # Test 1: Get definition of the 'thread' package import + result = await lsp.request_definition(path, 2, 13) + + assert isinstance(result, list) + assert len(result) >= 1 + + + item = result[0] + assert "fmt" in item["uri"] + + # Test 2: Find references to the 'name' variable + result = await lsp.request_references(path, 12, 1) # Position of name declaration + assert isinstance(result, list) + assert len(result) == 3 + + for item in result: + del item["uri"] + del item["absolutePath"] + + expected_results = [ + { + "range": { + "start": { "line": 36, "character": 2 }, + "end": { "line": 36, "character": 6 } + }, + "relativePath": "absolute_beginners\\5_structs.odin" + }, + { + "range": { + "start": { "line": 28, "character": 6 }, + "end": { "line": 28, "character": 10 } + }, + "relativePath": "absolute_beginners\\5_structs.odin" + }, + { + "range": { + "start": { "line": 46, "character": 2 }, + "end": { "line": 46, "character": 6 } + }, + "relativePath": "absolute_beginners\\5_structs.odin" + } + ] + + for expected in expected_results: + assert(expected in result) + + # # Test 3: Get hover information for the 'age' variable + result = await lsp.request_hover(path, 47, 2) + assert result is not None + assert "Cat.age: int" in result["contents"]["value"] + # Test 4: Get document symbols + result = await lsp.request_document_symbols(path) + assert isinstance(result, tuple) + + names = [symbol["name"] for symbol in result[0]] + assert len(names) == 4 + for name in ["Cat", "name", "age", "structs"]: + assert name in names From 670279fe86c01d2eaca479cd57f311bd196373e3 Mon Sep 17 00:00:00 2001 From: Guillaume Noailles Date: Sat, 6 Jun 2026 18:29:05 +0200 Subject: [PATCH 2/3] Update README --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e3acb87..a8d2853 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ pip install multilspy | kotlin | KotlinLanguageServer | | php | Intelephense | | cpp | clangd | +| odin | ols | ## Usage @@ -60,7 +61,7 @@ from multilspy import SyncLanguageServer from multilspy.multilspy_config import MultilspyConfig from multilspy.multilspy_logger import MultilspyLogger ... -config = MultilspyConfig.from_dict({"code_language": "java"}) # Also supports "python", "rust", "csharp", "typescript", "javascript", "go", "dart", "ruby", "kotlin", "php" +config = MultilspyConfig.from_dict({"code_language": "java"}) # Also supports "python", "rust", "csharp", "typescript", "javascript", "go", "dart", "ruby", "kotlin", "php", "odin" logger = MultilspyLogger() lsp = SyncLanguageServer.create(config, logger, "/abs/path/to/project/root/") with lsp.start_server(): From ef48106e8d44cf31242c4b0b71e75398fca7e2ec Mon Sep 17 00:00:00 2001 From: Guillaume Noailles Date: Sat, 6 Jun 2026 23:38:17 +0200 Subject: [PATCH 3/3] Setup runtime dependencies --- .../odin_language_server.py | 121 ++++++++++++------ .../runtime_dependencies.json | 70 ++++++++++ 2 files changed, 150 insertions(+), 41 deletions(-) create mode 100644 src/multilspy/language_servers/odin_language_server/runtime_dependencies.json diff --git a/src/multilspy/language_servers/odin_language_server/odin_language_server.py b/src/multilspy/language_servers/odin_language_server/odin_language_server.py index 67edc1e..db434b0 100644 --- a/src/multilspy/language_servers/odin_language_server/odin_language_server.py +++ b/src/multilspy/language_servers/odin_language_server/odin_language_server.py @@ -3,6 +3,7 @@ import logging import os import pathlib +import stat import subprocess from contextlib import asynccontextmanager from typing import AsyncIterator @@ -11,58 +12,92 @@ from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo from multilspy.lsp_protocol_handler.lsp_types import InitializeParams +from multilspy.multilspy_logger import MultilspyLogger +from multilspy.multilspy_config import MultilspyConfig +from multilspy.multilspy_utils import PlatformUtils, FileUtils, PlatformId +from multilspy.multilspy_settings import MultilspySettings + class Ols(LanguageServer): """ Provides Odin-specific instantiation of the LanguageServer class using ols. """ - @staticmethod - def _get_odin_version(): - """Get the installed Odin version or None if not found.""" - try: - result = subprocess.run(['odin', 'version'], capture_output=True, text=True) - if result.returncode == 0: - return result.stdout.strip() - except FileNotFoundError: - return None - return None + odin_root_path : str @staticmethod - def _get_ols_version(): - """Get the installed ols version or None if not found.""" + def _get_dependency_version(dependency: str): + """Get the installed ols or odin version or None if not found.""" try: - result = subprocess.run(['ols', 'version'], capture_output=True, text=True) + result = subprocess.run([dependency, 'version'], capture_output=True, text=True) if result.returncode == 0: return result.stdout.strip() except FileNotFoundError: return None return None + + + @staticmethod + def _setup_runtime_dependency(dependency: str, platform_id: PlatformId, logger: MultilspyLogger, config: MultilspyConfig): + """Setup odin or ols runtime dependency for Odin Language Server.""" + + assert dependency in ["odin", "ols"] + + dependency_version = Ols._get_dependency_version(dependency) + if dependency_version: + return dependency + else: + with open(os.path.join(os.path.dirname(__file__), "runtime_dependencies.json"), "r") as f: + d = json.load(f) + del d["_description"] + + dependencies = d[dependency] + dependencies = [ + dependency for dependency in dependencies if dependency["platformId"] == platform_id.value + ] + assert len(dependencies) == 1 + + # Select dependency matching the current platform + dependency = next((dep for dep in dependencies if dep["platformId"] == platform_id.value), None) + if dependency is None: + raise RuntimeError(f"No runtime dependency found for platform {platform_id.value}") + + ls_dir = config.server_install_dir or MultilspySettings.get_server_install_directory("ols") + executable_path = pathlib.PurePath(ls_dir, dependency["binaryName"]) + if not os.path.exists(executable_path): + os.makedirs(ls_dir, exist_ok=True) + FileUtils.download_and_extract_archive( + logger, dependency["url"], ls_dir, dependency["archiveType"] + ) + if not os.path.exists(executable_path): + raise FileNotFoundError(f"ols executable was not found at {executable_path} after extraction") + os.chmod(executable_path, stat.S_IEXEC) + + return str(executable_path) + @classmethod - def setup_runtime_dependency(cls): + def setup_runtime_dependencies(self, logger: MultilspyLogger, config: MultilspyConfig) -> str: """ - Check if required Odin runtime dependencies are available. - Raises RuntimeError with helpful message if dependencies are missing. + Setup runtime dependencies for Odin Language Server. """ - missing_deps = [] - - # Check for Odin installation - odin_version = cls._get_odin_version() - if not odin_version: - missing_deps.append(("Odin", "https://odin-lang.org/docs/install/")) - - # Check for ols - ols_version = cls._get_ols_version() - if not ols_version: - missing_deps.append(("ols", "https://github.com/DanielGavin/ols#installation")) - - if missing_deps: - error_msg = "Missing required dependencies:\n" - for dep, install_url in missing_deps: - error_msg += f"- {dep}: Please install from {install_url}\n" - raise RuntimeError(error_msg) - - return True + + platform_id = PlatformUtils.get_platform_id() + assert platform_id.value in [ + "linux-x64", + "win-x64", + "osx-x64", + "osx-arm64", + ], "Unsupported platform: " + platform_id.value + + self.odin_root_path = os.path.dirname(Ols._setup_runtime_dependency("odin", platform_id, logger, config)) + + if config.server_binary: + assert os.path.exists(config.server_binary), f"Server binary not found: {config.server_binary}" + return config.server_binary + + ols_executable_path = Ols._setup_runtime_dependency("ols", platform_id, logger, config) + + return ols_executable_path def __init__(self, config, logger, repository_root_path): @@ -71,14 +106,15 @@ def __init__(self, config, logger, repository_root_path): cmd = [config.server_binary] else: # Check runtime dependencies before initializing - self.setup_runtime_dependency() - cmd = ["ols"] + cmd = [self.setup_runtime_dependencies(logger, config)] + + proc_env = {"ODIN_ROOT": self.odin_root_path} super().__init__( config, logger, repository_root_path, - ProcessLaunchInfo(cmd=cmd, cwd=repository_root_path), + ProcessLaunchInfo(cmd=cmd, env = proc_env, cwd=repository_root_path), "odin", ) self.server_ready = asyncio.Event() @@ -124,10 +160,13 @@ async def do_nothing(params): ) init_response = await self.server.send.initialize(initialize_params) + capabilities = init_response["capabilities"] # Verify server capabilities - assert "textDocumentSync" in init_response["capabilities"] - assert "completionProvider" in init_response["capabilities"] - assert "definitionProvider" in init_response["capabilities"] + assert "completionProvider" in capabilities + assert "textDocumentSync" in capabilities and capabilities["textDocumentSync"]["openClose"] is True + assert "definitionProvider" in capabilities and capabilities["definitionProvider"] is True + assert "hoverProvider" in capabilities and capabilities["hoverProvider"] is True + assert "documentSymbolProvider" in capabilities and capabilities["documentSymbolProvider"] is True self.server.notify.initialized({}) self.completions_available.set() diff --git a/src/multilspy/language_servers/odin_language_server/runtime_dependencies.json b/src/multilspy/language_servers/odin_language_server/runtime_dependencies.json new file mode 100644 index 0000000..17d8748 --- /dev/null +++ b/src/multilspy/language_servers/odin_language_server/runtime_dependencies.json @@ -0,0 +1,70 @@ +{ + "_description": "Used to download the runtime dependencies for running Clangd.", + "odin": [ + { + "description": "Odin for Linux (x64)", + "url": "https://github.com/odin-lang/Odin/releases/download/dev-2026-05/odin-linux-amd64-dev-2026-05.tar.gz", + "platformId": "linux-x64", + "archiveType": "gztar", + "binaryName": "odin-linux-amd64-nightly+2026-05-03/odin" + }, + { + "description": "Odin for Windows (x64)", + "url": "https://github.com/odin-lang/Odin/releases/download/dev-2026-05/odin-windows-amd64-dev-2026-05.zip", + "platformId": "win-x64", + "archiveType": "zip", + "binaryName": "dist/odin.exe" + }, + { + "description": "Odin for macOS (x64)", + "url": "https://github.com/odin-lang/Odin/releases/download/dev-2026-05/odin-macos-amd64-dev-2026-05.tar.gz", + "platformId": "osx-x64", + "archiveType": "gztar", + "binaryName": "odin-macos-amd64-nightly+2026-05-03/odin" + }, + { + "description": "Odin for macOS (Arm64)", + "url": "https://github.com/odin-lang/Odin/releases/download/dev-2026-05/odin-macos-arm64-dev-2026-05.tar.gz", + "platformId": "osx-arm64", + "archiveType": "gztar", + "binaryName": "odin-macos-arm64-nightly+2026-05-03/odin" + } + ], + "ols": [ + { + "description": "Ols for Linux (x64)", + "url": "https://github.com/DanielGavin/ols/releases/download/dev-2026-05/ols-x86_64-unknown-linux-gnu.zip", + "platformId": "linux-x64", + "archiveType": "zip", + "binaryName": "ols-x86_64-unknown-linux-gnu" + }, + { + "description": "Ols for Windows (x64)", + "url": "https://github.com/DanielGavin/ols/releases/download/dev-2026-05/ols-x86_64-pc-windows-msvc.zip", + "platformId": "win-x64", + "archiveType": "zip", + "binaryName": "ols-x86_64-pc-windows-msvc.exe" + }, + { + "description": "Ols for macOS (x64)", + "url": "https://github.com/DanielGavin/ols/releases/download/dev-2026-05/ols-x86_64-darwin.zip", + "platformId": "osx-x64", + "archiveType": "zip", + "binaryName": "ols-x86_64-darwin" + }, + { + "description": "Ols for Linux (Arm64)", + "url": "https://github.com/DanielGavin/ols/releases/download/dev-2026-05/ols-arm64-unknown-linux-gnu.zip", + "platformId": "linux-arm64", + "archiveType": "zip", + "binaryName": "ols-arm64-unknown-linux-gnu" + }, + { + "description": "Ols for macOS (Arm64)", + "url": "https://github.com/DanielGavin/ols/releases/download/dev-2026-05/ols-arm64-darwin.zip", + "platformId": "osx-arm64", + "archiveType": "zip", + "binaryName": "ols-arm64-darwin" + } + ] +}