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(): 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..db434b0 --- /dev/null +++ b/src/multilspy/language_servers/odin_language_server/odin_language_server.py @@ -0,0 +1,181 @@ +import asyncio +import json +import logging +import os +import pathlib +import stat +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 + +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. + """ + + odin_root_path : str + + @staticmethod + def _get_dependency_version(dependency: str): + """Get the installed ols or odin version or None if not found.""" + try: + 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_dependencies(self, logger: MultilspyLogger, config: MultilspyConfig) -> str: + """ + Setup runtime dependencies for Odin Language Server. + """ + + 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): + 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 + 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, env = proc_env, 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) + + capabilities = init_response["capabilities"] + # Verify server 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() + + # 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/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" + } + ] +} 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