diff --git a/src/multilspy/language_server.py b/src/multilspy/language_server.py index bd05b27..b9b903d 100644 --- a/src/multilspy/language_server.py +++ b/src/multilspy/language_server.py @@ -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. """ @@ -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") @@ -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. @@ -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[] @@ -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}" @@ -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"] @@ -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"]: @@ -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}" @@ -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 [] @@ -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 @@ -651,7 +659,7 @@ async def request_hover(self, relative_file_path: str, line: int, column: int) - }, } ) - + if response is None: return None @@ -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. @@ -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"]: """ diff --git a/src/multilspy/language_servers/elixir_language_server/elixir_language_server.py b/src/multilspy/language_servers/elixir_language_server/elixir_language_server.py new file mode 100644 index 0000000..f3adfd8 --- /dev/null +++ b/src/multilspy/language_servers/elixir_language_server/elixir_language_server.py @@ -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() \ No newline at end of file diff --git a/src/multilspy/language_servers/elixir_language_server/initialize_params.json b/src/multilspy/language_servers/elixir_language_server/initialize_params.json new file mode 100644 index 0000000..bf8c6ae --- /dev/null +++ b/src/multilspy/language_servers/elixir_language_server/initialize_params.json @@ -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" + } + ] +} diff --git a/src/multilspy/language_servers/elixir_language_server/runtime_dependencies.json b/src/multilspy/language_servers/elixir_language_server/runtime_dependencies.json new file mode 100644 index 0000000..1fb8637 --- /dev/null +++ b/src/multilspy/language_servers/elixir_language_server/runtime_dependencies.json @@ -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" + } + ] +} diff --git a/src/multilspy/multilspy_config.py b/src/multilspy/multilspy_config.py index 86c6a6c..8d1ad78 100644 --- a/src/multilspy/multilspy_config.py +++ b/src/multilspy/multilspy_config.py @@ -21,6 +21,7 @@ class Language(str, Enum): RUBY = "ruby" DART = "dart" CPP = "cpp" + ELIXIR = "elixir" def __str__(self) -> str: return self.value diff --git a/tests/multilspy/test_multilspy_elixir.py b/tests/multilspy/test_multilspy_elixir.py new file mode 100644 index 0000000..ce18c76 --- /dev/null +++ b/tests/multilspy/test_multilspy_elixir.py @@ -0,0 +1,439 @@ +""" +This file contains tests for running the Elixir Language Server: ElixirLS +""" + +import pytest +import os +import tempfile +import contextlib +from pathlib import PurePath +from typing import Iterator + +from multilspy import LanguageServer +from multilspy.multilspy_config import Language, MultilspyConfig +from multilspy.multilspy_logger import MultilspyLogger +from tests.multilspy.multilspy_context import MultilspyContext + +pytest_plugins = ("pytest_asyncio",) + + +@contextlib.contextmanager +def create_elixir_test_project() -> Iterator[str]: + """ + Create a self-contained Elixir test project with multiple modules and functions. + Returns the path to the project directory. + """ + with tempfile.TemporaryDirectory() as temp_dir: + project_dir = os.path.join(temp_dir, "test_elixir_project") + os.makedirs(project_dir) + + # Create mix.exs + mix_exs_content = '''defmodule TestElixirProject.MixProject do + use Mix.Project + + def project do + [ + app: :test_elixir_project, + version: "0.1.0", + elixir: "~> 1.14", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + def application do + [ + extra_applications: [:logger] + ] + end + + defp deps do + [] + end +end +''' + with open(os.path.join(project_dir, "mix.exs"), "w") as f: + f.write(mix_exs_content) + + # Create lib directory + lib_dir = os.path.join(project_dir, "lib") + os.makedirs(lib_dir) + + # Create main module + main_module_content = '''defmodule TestElixirProject do + @moduledoc """ + Documentation for `TestElixirProject`. + """ + + @doc """ + Hello world function. + """ + def hello do + :world + end + + @doc """ + Adds two numbers together. + """ + def add(a, b) do + a + b + end + + def greet(name) do + "Hello, #{name}!" + end + + defp private_helper do + "This is a private function" + end +end +''' + with open(os.path.join(lib_dir, "test_elixir_project.ex"), "w") as f: + f.write(main_module_content) + + # Create a math utility module + math_module_content = '''defmodule TestElixirProject.Math do + @moduledoc """ + Math utilities for the test project. + """ + + @doc """ + Multiplies two numbers. + """ + def multiply(a, b) do + a * b + end + + @doc """ + Calculates the square of a number. + """ + def square(n) do + multiply(n, n) + end + + @doc """ + Divides two numbers, returns {:ok, result} or {:error, reason}. + """ + def divide(a, b) when b != 0 do + {:ok, a / b} + end + + def divide(_a, 0) do + {:error, "Cannot divide by zero"} + end +end +''' + with open(os.path.join(lib_dir, "math.ex"), "w") as f: + f.write(math_module_content) + + # Create a server module that calls other functions + server_module_content = '''defmodule TestElixirProject.Server do + @moduledoc """ + A simple server module that demonstrates function calls. + """ + + alias TestElixirProject.Math + + @doc """ + Starts the server. + """ + def start do + {:ok, :started} + end + + @doc """ + Processes a calculation request. + """ + def calculate(:add, a, b) do + TestElixirProject.add(a, b) + end + + def calculate(:multiply, a, b) do + Math.multiply(a, b) + end + + def calculate(:square, n) do + Math.square(n) + end + + def process_greeting(name) do + TestElixirProject.greet(name) + end + + defp log_operation(op, result) do + "Operation #{op} completed with result: #{result}" + end +end +''' + with open(os.path.join(lib_dir, "server.ex"), "w") as f: + f.write(server_module_content) + + # Create test directory and files + test_dir = os.path.join(project_dir, "test") + os.makedirs(test_dir) + + test_helper_content = '''ExUnit.start() +''' + with open(os.path.join(test_dir, "test_helper.exs"), "w") as f: + f.write(test_helper_content) + + test_module_content = '''defmodule TestElixirProjectTest do + use ExUnit.Case + doctest TestElixirProject + + test "greets the world" do + assert TestElixirProject.hello() == :world + end + + test "adds two numbers" do + assert TestElixirProject.add(2, 3) == 5 + end + + test "math operations" do + assert TestElixirProject.Math.multiply(4, 5) == 20 + assert TestElixirProject.Math.square(3) == 9 + end +end +''' + with open(os.path.join(test_dir, "test_elixir_project_test.exs"), "w") as f: + f.write(test_module_content) + + # Create .formatter.exs + formatter_content = '''[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] +''' + with open(os.path.join(project_dir, ".formatter.exs"), "w") as f: + f.write(formatter_content) + + yield project_dir + + +@contextlib.contextmanager +def create_elixir_test_context() -> Iterator[MultilspyContext]: + """ + Creates a test context with a local Elixir project. + """ + with create_elixir_test_project() as project_dir: + config = MultilspyConfig.from_dict({ + "code_language": Language.ELIXIR, + "request_timeout": 30, + "completions_timeout": 30 + }) + logger = MultilspyLogger() + yield MultilspyContext(config, logger, project_dir) + + +@pytest.mark.asyncio +async def test_multilspy_elixir_basic_functionality(): + """ + Test basic ElixirLS functionality with a self-contained project + """ + with create_elixir_test_context() as context: + lsp = LanguageServer.create(context.config, context.logger, context.source_directory) + + async with lsp.start_server(): + # Test 1: Document symbols - should find modules and functions + result = await lsp.request_document_symbols(str(PurePath("lib/test_elixir_project.ex"))) + assert isinstance(result, tuple) + assert len(result) == 2 # (symbols, errors) + + if result[0]: # If we have symbols + symbol_names = [symbol["name"] for symbol in result[0]] + # Should find the main module and its functions + assert "TestElixirProject" in symbol_names + assert any("hello" in name for name in symbol_names) + assert any("add" in name for name in symbol_names) + + # Test 2: Document symbols for math module + result = await lsp.request_document_symbols(str(PurePath("lib/math.ex"))) + assert isinstance(result, tuple) + assert len(result) == 2 + + if result[0]: + symbol_names = [symbol["name"] for symbol in result[0]] + assert "TestElixirProject.Math" in symbol_names + assert any("multiply" in name for name in symbol_names) + + +@pytest.mark.asyncio +async def test_multilspy_elixir_definitions(): + """ + Test definition requests for local function calls + """ + with create_elixir_test_context() as context: + lsp = LanguageServer.create(context.config, context.logger, context.source_directory) + + async with lsp.start_server(): + # Test definition for Math.multiply call in server.ex + # Line ~28: Math.multiply(a, b) + result = await lsp.request_definition(str(PurePath("lib/server.ex")), 27, 10) + assert isinstance(result, list) + + # Test definition for TestElixirProject.add call + # Line ~24: TestElixirProject.add(a, b) + result = await lsp.request_definition(str(PurePath("lib/server.ex")), 23, 20) + assert isinstance(result, list) + + # Test definition for Math.square call + # Line ~32: Math.square(n) + result = await lsp.request_definition(str(PurePath("lib/server.ex")), 31, 10) + assert isinstance(result, list) + + +@pytest.mark.asyncio +async def test_multilspy_elixir_hover(): + """ + Test hover information for functions and modules + """ + with create_elixir_test_context() as context: + lsp = LanguageServer.create(context.config, context.logger, context.source_directory) + + async with lsp.start_server(): + # Test hover on 'defmodule' keyword - should return hover info + result = await lsp.request_hover(str(PurePath("lib/test_elixir_project.ex")), 0, 8) + assert isinstance(result, dict), "Should return hover info for defmodule keyword" + + # Test hover on module name "TestElixirProject" in defmodule declaration + result = await lsp.request_hover(str(PurePath("lib/test_elixir_project.ex")), 0, 20) + assert isinstance(result, dict), "Should return hover info for module name" + + # Test hover on :world atom - should return hover info + result = await lsp.request_hover(str(PurePath("lib/test_elixir_project.ex")), 8, 4) + assert isinstance(result, dict), "Should return hover info for atom" + + +@pytest.mark.asyncio +async def test_multilspy_elixir_hover_none_cases(): + """ + Test hover positions that should specifically return None + """ + with create_elixir_test_context() as context: + lsp = LanguageServer.create(context.config, context.logger, context.source_directory) + + async with lsp.start_server(): + # Test hover on function name in definition - should return None (you're already looking at the definition) + result = await lsp.request_hover(str(PurePath("lib/test_elixir_project.ex")), 7, 6) + assert result is None, "Should return None for function name in its own definition" + + # Test hover on parameter name - should return None + result = await lsp.request_hover(str(PurePath("lib/test_elixir_project.ex")), 14, 4) + assert result is None, "Should return None for parameter name" + + # Test hover on whitespace - should return None + result = await lsp.request_hover(str(PurePath("lib/test_elixir_project.ex")), 1, 0) + assert result is None, "Should return None for whitespace position" + + +@pytest.mark.asyncio +async def test_multilspy_elixir_workspace_symbols(): + """ + Test workspace symbol functionality + """ + with create_elixir_test_context() as context: + lsp = LanguageServer.create(context.config, context.logger, context.source_directory) + + async with lsp.start_server(): + # Wait a bit for indexing + import asyncio + await asyncio.sleep(5) + + # Test searching for modules + result = await lsp.request_workspace_symbol("TestElixirProject") + assert isinstance(result, list) + + # Test searching for functions + result = await lsp.request_workspace_symbol("multiply") + assert isinstance(result, list) + + # Test empty search (should return all symbols if any) + result = await lsp.request_workspace_symbol("") + assert isinstance(result, list) + + +@pytest.mark.asyncio +async def test_multilspy_elixir_references(): + """ + Test finding references to functions and modules + """ + with create_elixir_test_context() as context: + lsp = LanguageServer.create(context.config, context.logger, context.source_directory) + + async with lsp.start_server(): + # Test references for Math.multiply function + result = await lsp.request_references(str(PurePath("lib/math.ex")), 6, 6) + assert isinstance(result, list) + + # Clean up references results for comparison + for item in result: + if "uri" in item: + del item["uri"] + if "absolutePath" in item: + del item["absolutePath"] + + # Test references for hello function + result = await lsp.request_references(str(PurePath("lib/test_elixir_project.ex")), 6, 6) + assert isinstance(result, list) + + +@pytest.mark.asyncio +async def test_multilspy_elixir_multiple_files(): + """ + Test functionality across multiple files + """ + with create_elixir_test_context() as context: + lsp = LanguageServer.create(context.config, context.logger, context.source_directory) + + async with lsp.start_server(): + # Test symbols across multiple files + files_to_test = [ + "lib/test_elixir_project.ex", + "lib/math.ex", + "lib/server.ex" + ] + + all_symbols = [] + for file_path in files_to_test: + result = await lsp.request_document_symbols(str(PurePath(file_path))) + assert isinstance(result, tuple) + assert len(result) == 2 + + if result[0]: + all_symbols.extend([symbol["name"] for symbol in result[0]]) + + # Verify we found symbols from multiple modules + assert len(all_symbols) > 0 + assert any("TestElixirProject" in symbol for symbol in all_symbols) + assert any("Math" in symbol for symbol in all_symbols) + assert any("Server" in symbol for symbol in all_symbols) + + +@pytest.mark.asyncio +async def test_multilspy_elixir_error_handling(): + """ + Test ElixirLS error handling and edge cases + """ + with create_elixir_test_context() as context: + lsp = LanguageServer.create(context.config, context.logger, context.source_directory) + + async with lsp.start_server(): + # Test definition request on non-existent file should handle gracefully + try: + result = await lsp.request_definition("lib/nonexistent.ex", 1, 1) + # Should return empty list or handle gracefully + assert isinstance(result, list) + except Exception: + # It's acceptable for this to raise an exception + pass + + # Test out-of-bounds position should handle gracefully + result = await lsp.request_definition(str(PurePath("lib/test_elixir_project.ex")), 99999, 99999) + assert isinstance(result, list) + + # Test hover on edge case position - should return None + result = await lsp.request_hover(str(PurePath("lib/test_elixir_project.ex")), 99999, 99999) + assert result is None, "Should return None for out-of-bounds position" + + # Test hover on whitespace - should return None + result = await lsp.request_hover(str(PurePath("lib/test_elixir_project.ex")), 1, 0) + assert result is None, "Should return None for whitespace position" \ No newline at end of file diff --git a/tests/multilspy/test_sync_multilspy_elixir.py b/tests/multilspy/test_sync_multilspy_elixir.py new file mode 100644 index 0000000..bdbf70a --- /dev/null +++ b/tests/multilspy/test_sync_multilspy_elixir.py @@ -0,0 +1,406 @@ +""" +This file contains tests for running the Elixir Language Server: ElixirLS using sync interface +""" + +import os +import tempfile +import contextlib +from pathlib import PurePath +from typing import Iterator + +from multilspy import SyncLanguageServer +from multilspy.multilspy_config import Language, MultilspyConfig +from multilspy.multilspy_logger import MultilspyLogger +from tests.multilspy.multilspy_context import MultilspyContext + + +@contextlib.contextmanager +def create_elixir_test_project() -> Iterator[str]: + """ + Create a self-contained Elixir test project with multiple modules and functions. + Returns the path to the project directory. + """ + with tempfile.TemporaryDirectory() as temp_dir: + project_dir = os.path.join(temp_dir, "test_elixir_project") + os.makedirs(project_dir) + + # Create mix.exs + mix_exs_content = '''defmodule TestElixirProject.MixProject do + use Mix.Project + + def project do + [ + app: :test_elixir_project, + version: "0.1.0", + elixir: "~> 1.14", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + def application do + [ + extra_applications: [:logger] + ] + end + + defp deps do + [] + end +end +''' + with open(os.path.join(project_dir, "mix.exs"), "w") as f: + f.write(mix_exs_content) + + # Create lib directory + lib_dir = os.path.join(project_dir, "lib") + os.makedirs(lib_dir) + + # Create main module + main_module_content = '''defmodule TestElixirProject do + @moduledoc """ + Documentation for `TestElixirProject`. + """ + + @doc """ + Hello world function. + """ + def hello do + :world + end + + @doc """ + Adds two numbers together. + """ + def add(a, b) do + a + b + end + + def greet(name) do + "Hello, #{name}!" + end + + defp private_helper do + "This is a private function" + end +end +''' + with open(os.path.join(lib_dir, "test_elixir_project.ex"), "w") as f: + f.write(main_module_content) + + # Create a math utility module + math_module_content = '''defmodule TestElixirProject.Math do + @moduledoc """ + Math utilities for the test project. + """ + + @doc """ + Multiplies two numbers. + """ + def multiply(a, b) do + a * b + end + + @doc """ + Calculates the square of a number. + """ + def square(n) do + multiply(n, n) + end + + @doc """ + Divides two numbers, returns {:ok, result} or {:error, reason}. + """ + def divide(a, b) when b != 0 do + {:ok, a / b} + end + + def divide(_a, 0) do + {:error, "Cannot divide by zero"} + end +end +''' + with open(os.path.join(lib_dir, "math.ex"), "w") as f: + f.write(math_module_content) + + # Create a server module that calls other functions + server_module_content = '''defmodule TestElixirProject.Server do + @moduledoc """ + A simple server module that demonstrates function calls. + """ + + alias TestElixirProject.Math + + @doc """ + Starts the server. + """ + def start do + {:ok, :started} + end + + @doc """ + Processes a calculation request. + """ + def calculate(:add, a, b) do + TestElixirProject.add(a, b) + end + + def calculate(:multiply, a, b) do + Math.multiply(a, b) + end + + def calculate(:square, n) do + Math.square(n) + end + + def process_greeting(name) do + TestElixirProject.greet(name) + end + + defp log_operation(op, result) do + "Operation #{op} completed with result: #{result}" + end +end +''' + with open(os.path.join(lib_dir, "server.ex"), "w") as f: + f.write(server_module_content) + + # Create test directory and files + test_dir = os.path.join(project_dir, "test") + os.makedirs(test_dir) + + test_helper_content = '''ExUnit.start() +''' + with open(os.path.join(test_dir, "test_helper.exs"), "w") as f: + f.write(test_helper_content) + + test_module_content = '''defmodule TestElixirProjectTest do + use ExUnit.Case + doctest TestElixirProject + + test "greets the world" do + assert TestElixirProject.hello() == :world + end + + test "adds two numbers" do + assert TestElixirProject.add(2, 3) == 5 + end + + test "math operations" do + assert TestElixirProject.Math.multiply(4, 5) == 20 + assert TestElixirProject.Math.square(3) == 9 + end +end +''' + with open(os.path.join(test_dir, "test_elixir_project_test.exs"), "w") as f: + f.write(test_module_content) + + # Create .formatter.exs + formatter_content = '''[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] +''' + with open(os.path.join(project_dir, ".formatter.exs"), "w") as f: + f.write(formatter_content) + + yield project_dir + + +@contextlib.contextmanager +def create_elixir_test_context() -> Iterator[MultilspyContext]: + """ + Creates a test context with a local Elixir project. + """ + with create_elixir_test_project() as project_dir: + config = MultilspyConfig.from_dict({ + "code_language": Language.ELIXIR, + "request_timeout": 30, + "completions_timeout": 30 + }) + logger = MultilspyLogger() + yield MultilspyContext(config, logger, project_dir) + + +def test_multilspy_elixir_basic_functionality_sync() -> None: + """ + Test basic ElixirLS functionality with sync interface + """ + with create_elixir_test_context() as context: + lsp = SyncLanguageServer.create(context.config, context.logger, context.source_directory) + + with lsp.start_server(): + # Test 1: Document symbols - should find modules and functions + result = lsp.request_document_symbols(str(PurePath("lib/test_elixir_project.ex"))) + assert isinstance(result, tuple) + assert len(result) == 2 # (symbols, errors) + + if result[0]: # If we have symbols + symbol_names = [symbol["name"] for symbol in result[0]] + # Should find the main module and its functions + assert "TestElixirProject" in symbol_names + assert any("hello" in name for name in symbol_names) + + # Test 2: Document symbols for math module + result = lsp.request_document_symbols(str(PurePath("lib/math.ex"))) + assert isinstance(result, tuple) + assert len(result) == 2 + + if result[0]: + symbol_names = [symbol["name"] for symbol in result[0]] + assert "TestElixirProject.Math" in symbol_names + + +def test_multilspy_elixir_definitions_sync() -> None: + """ + Test definition requests with sync interface + """ + with create_elixir_test_context() as context: + lsp = SyncLanguageServer.create(context.config, context.logger, context.source_directory) + + with lsp.start_server(): + # Test definition for Math.multiply call in server.ex + result = lsp.request_definition(str(PurePath("lib/server.ex")), 27, 10) + assert isinstance(result, list) + + # Test definition for TestElixirProject.add call + result = lsp.request_definition(str(PurePath("lib/server.ex")), 23, 20) + assert isinstance(result, list) + + +def test_multilspy_elixir_hover_sync() -> None: + """ + Test hover functionality with sync interface + """ + with create_elixir_test_context() as context: + lsp = SyncLanguageServer.create(context.config, context.logger, context.source_directory) + + with lsp.start_server(): + # Test hover on 'defmodule' keyword - should return hover info + result = lsp.request_hover(str(PurePath("lib/test_elixir_project.ex")), 0, 8) + assert isinstance(result, dict), "Should return hover info for defmodule keyword" + + # Test hover on module name "TestElixirProject" in defmodule declaration + result = lsp.request_hover(str(PurePath("lib/test_elixir_project.ex")), 0, 20) + assert isinstance(result, dict), "Should return hover info for module name" + + # Test hover on :world atom - should return hover info + result = lsp.request_hover(str(PurePath("lib/test_elixir_project.ex")), 8, 4) + assert isinstance(result, dict), "Should return hover info for atom" + + +def test_multilspy_elixir_hover_none_cases_sync() -> None: + """ + Test hover positions that should specifically return None with sync interface + """ + with create_elixir_test_context() as context: + lsp = SyncLanguageServer.create(context.config, context.logger, context.source_directory) + + with lsp.start_server(): + # Test hover on function name in definition - should return None (you're already looking at the definition) + result = lsp.request_hover(str(PurePath("lib/test_elixir_project.ex")), 7, 6) + assert result is None, "Should return None for function name in its own definition" + + # Test hover on parameter name - should return None + result = lsp.request_hover(str(PurePath("lib/test_elixir_project.ex")), 14, 4) + assert result is None, "Should return None for parameter name" + + # Test hover on whitespace - should return None + result = lsp.request_hover(str(PurePath("lib/test_elixir_project.ex")), 1, 0) + assert result is None, "Should return None for whitespace position" + + +def test_multilspy_elixir_workspace_symbols_sync() -> None: + """ + Test workspace symbols with sync interface + """ + with create_elixir_test_context() as context: + lsp = SyncLanguageServer.create(context.config, context.logger, context.source_directory) + + with lsp.start_server(): + # Test searching for modules + result = lsp.request_workspace_symbol("TestElixirProject") + assert isinstance(result, list) + + # Test searching for functions + result = lsp.request_workspace_symbol("multiply") + assert isinstance(result, list) + + +def test_multilspy_elixir_references_sync() -> None: + """ + Test references functionality with sync interface + """ + with create_elixir_test_context() as context: + lsp = SyncLanguageServer.create(context.config, context.logger, context.source_directory) + + with lsp.start_server(): + # Test references for Math.multiply function + result = lsp.request_references(str(PurePath("lib/math.ex")), 6, 6) + assert isinstance(result, list) + + # Clean up references results for comparison + for item in result: + if "uri" in item: + del item["uri"] + if "absolutePath" in item: + del item["absolutePath"] + + +def test_multilspy_elixir_multiple_files_sync() -> None: + """ + Test sync interface across multiple files + """ + with create_elixir_test_context() as context: + lsp = SyncLanguageServer.create(context.config, context.logger, context.source_directory) + + with lsp.start_server(): + # Test symbols across multiple files + files_to_test = [ + "lib/test_elixir_project.ex", + "lib/math.ex", + "lib/server.ex" + ] + + all_symbols = [] + for file_path in files_to_test: + result = lsp.request_document_symbols(str(PurePath(file_path))) + assert isinstance(result, tuple) + assert len(result) == 2 + + if result[0]: + all_symbols.extend([symbol["name"] for symbol in result[0]]) + + # Verify we found symbols from multiple modules + assert len(all_symbols) > 0 + assert any("TestElixirProject" in symbol for symbol in all_symbols) + assert any("Math" in symbol for symbol in all_symbols) + + +def test_multilspy_elixir_error_handling_sync() -> None: + """ + Test error handling with sync interface + """ + with create_elixir_test_context() as context: + lsp = SyncLanguageServer.create(context.config, context.logger, context.source_directory) + + with lsp.start_server(): + # Test definition request on non-existent file should handle gracefully + try: + result = lsp.request_definition("lib/nonexistent.ex", 1, 1) + # Should return empty list or handle gracefully + assert isinstance(result, list) + except Exception: + # It's acceptable for this to raise an exception + pass + + # Test out-of-bounds position should handle gracefully + result = lsp.request_definition(str(PurePath("lib/test_elixir_project.ex")), 99999, 99999) + assert isinstance(result, list) + + # Test hover on edge case position - should return None + result = lsp.request_hover(str(PurePath("lib/test_elixir_project.ex")), 99999, 99999) + assert result is None, "Should return None for out-of-bounds position" + + # Test hover on whitespace - should return None + result = lsp.request_hover(str(PurePath("lib/test_elixir_project.ex")), 1, 0) + assert result is None, "Should return None for whitespace position" \ No newline at end of file