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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ pip install multilspy
| kotlin | KotlinLanguageServer |
| php | Intelephense |
| cpp | clangd |
| odin | ols |


## Usage
Expand All @@ -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():
Expand Down
4 changes: 4 additions & 0 deletions src/multilspy/language_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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()
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
1 change: 1 addition & 0 deletions src/multilspy/multilspy_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class Language(str, Enum):
CPP = "cpp"
PHP = "php"
ELIXIR = "elixir"
ODIN = "odin"

def __str__(self) -> str:
return self.value
Expand Down
90 changes: 90 additions & 0 deletions tests/multilspy/test_multilspy_odin.py
Original file line number Diff line number Diff line change
@@ -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