diff --git a/.gitignore b/.gitignore index 8464aab..c7cc380 100644 --- a/.gitignore +++ b/.gitignore @@ -403,7 +403,8 @@ src/multilspy/language_servers/omnisharp/static/ src/multilspy/language_servers/rust_analyzer/static/ src/multilspy/language_servers/typescript_language_server/static/ src/multilspy/language_servers/dart_language_server/static/ +src/multilspy/language_servers/clangd_language_server/static/ # Virtual Environment .venv/ -venv/ \ No newline at end of file +venv/ diff --git a/src/multilspy/language_server.py b/src/multilspy/language_server.py index 89624f5..bd05b27 100644 --- a/src/multilspy/language_server.py +++ b/src/multilspy/language_server.py @@ -118,6 +118,10 @@ def create(cls, config: MultilspyConfig, logger: MultilspyLogger, repository_roo from multilspy.language_servers.dart_language_server.dart_language_server import DartLanguageServer return DartLanguageServer(config, logger, repository_root_path) + elif config.code_language == Language.CPP: + from multilspy.language_servers.clangd_language_server.clangd_language_server import ClangdLanguageServer + + return ClangdLanguageServer(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") diff --git a/src/multilspy/language_servers/clangd_language_server/clangd_language_server.py b/src/multilspy/language_servers/clangd_language_server/clangd_language_server.py new file mode 100644 index 0000000..17253c4 --- /dev/null +++ b/src/multilspy/language_servers/clangd_language_server/clangd_language_server.py @@ -0,0 +1,180 @@ +""" +Provides C/C++ specific instantiation of the LanguageServer class. Contains various configurations and settings specific to C/C++. +""" + +import asyncio +import json +import logging +import os +import stat +import pathlib +from contextlib import asynccontextmanager +from typing import AsyncIterator + +from multilspy.multilspy_logger import MultilspyLogger +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_config import MultilspyConfig +from multilspy.multilspy_utils import FileUtils +from multilspy.multilspy_utils import PlatformUtils + + +class ClangdLanguageServer(LanguageServer): + """ + Provides C/C++ specific instantiation of the LanguageServer class. Contains various configurations and settings specific to C/C++. + As the project gets bigger in size, building index will take time. Try running clangd multiple times to ensure index is built properly. + Also make sure compile_commands.json is created at root of the source directory. Check clangd test case for example. + """ + + def __init__(self, config: MultilspyConfig, logger: MultilspyLogger, repository_root_path: str): + """ + Creates a ClangdLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. + """ + clangd_executable_path = self.setup_runtime_dependencies(logger, config) + super().__init__( + config, + logger, + repository_root_path, + ProcessLaunchInfo(cmd=clangd_executable_path, cwd=repository_root_path), + "cpp", + ) + self.server_ready = asyncio.Event() + + def setup_runtime_dependencies(self, logger: MultilspyLogger, config: MultilspyConfig) -> str: + """ + Setup runtime dependencies for ClangdLanguageServer. + """ + 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"] + + assert platform_id.value in [ + "linux-x64" + ], "Only linux-x64 is supported for in multilspy at the moment" + + runtime_dependencies = d["runtimeDependencies"] + runtime_dependencies = [ + dependency for dependency in runtime_dependencies if dependency["platformId"] == platform_id.value + ] + assert len(runtime_dependencies) == 1 + dependency = runtime_dependencies[0] + + clangd_ls_dir = os.path.join(os.path.dirname(__file__), "static/clangd") + clangd_executable_path = os.path.join(clangd_ls_dir, "clangd_19.1.2", "bin", dependency["binaryName"]) + if not os.path.exists(clangd_ls_dir): + os.makedirs(clangd_ls_dir) + if dependency["archiveType"] == "zip": + FileUtils.download_and_extract_archive( + logger, dependency["url"], clangd_ls_dir, dependency["archiveType"] + ) + assert os.path.exists(clangd_executable_path) + os.chmod(clangd_executable_path, stat.S_IEXEC) + + return clangd_executable_path + + def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize params for the clangd Language Server. + """ + 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() + assert d["rootPath"] == "$rootPath" + d["rootPath"] = repository_absolute_path + + assert d["rootUri"] == "$rootUri" + d["rootUri"] = pathlib.Path(repository_absolute_path).as_uri() + + assert d["workspaceFolders"][0]["uri"] == "$uri" + d["workspaceFolders"][0]["uri"] = pathlib.Path(repository_absolute_path).as_uri() + + assert d["workspaceFolders"][0]["name"] == "$name" + d["workspaceFolders"][0]["name"] = os.path.basename(repository_absolute_path) + + return d + + @asynccontextmanager + async def start_server(self) -> AsyncIterator["ClangdLanguageServer"]: + """ + Starts the Clangd Language Server, waits for the server to be ready and yields the LanguageServer instance. + + Usage: + ``` + async with lsp.start_server(): + # LanguageServer has been initialized and ready to serve requests + await lsp.request_definition(...) + await lsp.request_references(...) + # Shutdown the LanguageServer on exit from scope + # LanguageServer has been shutdown + """ + async def register_capability_handler(params): + assert "registrations" in params + for registration in params["registrations"]: + if registration["method"] == "workspace/executeCommand": + self.initialize_searcher_command_available.set() + self.resolve_main_method_available.set() + return + + async def lang_status_handler(params): + # TODO: Should we wait for + # server -> client: {'jsonrpc': '2.0', 'method': 'language/status', 'params': {'type': 'ProjectStatus', 'message': 'OK'}} + # Before proceeding? + if params["type"] == "ServiceReady" and params["message"] == "ServiceReady": + self.service_ready_event.set() + + async def execute_client_command_handler(params): + return [] + + async def do_nothing(params): + return + + async def check_experimental_status(params): + if params["quiescent"] == True: + self.server_ready.set() + + async def window_log_message(msg): + self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("language/status", lang_status_handler) + self.server.on_notification("window/logMessage", 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) + self.server.on_notification("experimental/serverStatus", check_experimental_status) + + async with super().start_server(): + self.logger.log("Starting Clangd server process", logging.INFO) + await self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + 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) + assert init_response["capabilities"]["textDocumentSync"]["change"] == 2 + assert "completionProvider" in init_response["capabilities"] + assert init_response["capabilities"]["completionProvider"] == { + "triggerCharacters": ['.', '<', '>', ':', '"', '/', '*'], + "resolveProvider": False, + } + + self.server.notify.initialized({}) + + self.completions_available.set() + # set ready flag + self.server_ready.set() + await self.server_ready.wait() + + yield self + + await self.server.shutdown() + await self.server.stop() diff --git a/src/multilspy/language_servers/clangd_language_server/initialize_params.json b/src/multilspy/language_servers/clangd_language_server/initialize_params.json new file mode 100644 index 0000000..4330560 --- /dev/null +++ b/src/multilspy/language_servers/clangd_language_server/initialize_params.json @@ -0,0 +1,36 @@ +{ + "_description": "The parameters sent by the client when initializing the language server with the \"initialize\" request. More details at https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialize", + "processId": "os.getpid()", + "locale": "en", + "rootPath": "$rootPath", + "rootUri": "$rootUri", + "capabilities": { + "textDocument": { + "synchronization": { + "didSave": true, + "dynamicRegistration": true + }, + "completion": { + "dynamicRegistration": true, + "completionItem": { + "snippetSupport": true + } + }, + "definition": { + "dynamicRegistration": true + } + }, + "workspace": { + "workspaceFolders": true, + "didChangeConfiguration": { + "dynamicRegistration": true + } + } + }, + "workspaceFolders": [ + { + "uri": "$uri", + "name": "$name" + } + ] +} \ No newline at end of file diff --git a/src/multilspy/language_servers/clangd_language_server/runtime_dependencies.json b/src/multilspy/language_servers/clangd_language_server/runtime_dependencies.json new file mode 100644 index 0000000..ab30fff --- /dev/null +++ b/src/multilspy/language_servers/clangd_language_server/runtime_dependencies.json @@ -0,0 +1,13 @@ +{ + "_description": "Used to download the runtime dependencies for running Clangd.", + "runtimeDependencies": [ + { + "id": "Clangd", + "description": "Clangd for Linux (x64)", + "url": "https://github.com/clangd/clangd/releases/download/19.1.2/clangd-linux-19.1.2.zip", + "platformId": "linux-x64", + "archiveType": "zip", + "binaryName": "clangd" + } + ] +} diff --git a/src/multilspy/multilspy_config.py b/src/multilspy/multilspy_config.py index 31b68c3..86c6a6c 100644 --- a/src/multilspy/multilspy_config.py +++ b/src/multilspy/multilspy_config.py @@ -20,6 +20,7 @@ class Language(str, Enum): GO = "go" RUBY = "ruby" DART = "dart" + CPP = "cpp" def __str__(self) -> str: return self.value diff --git a/tests/multilspy/test_multilspy_clangd.py b/tests/multilspy/test_multilspy_clangd.py new file mode 100644 index 0000000..6c50ccd --- /dev/null +++ b/tests/multilspy/test_multilspy_clangd.py @@ -0,0 +1,70 @@ +""" +This file contains tests for running the C/CPP Language Server: clangd +""" + +import pytest +import os + +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",) + +def create_compile_commands_file(source_directory_path): + """ + clangd requires compile_commands.json file to resolve dependencies in project. + This file contains information such as how the source files are compiled. + This file can be generated using different tools. CMake is used here to generate + this file. For other options check: https://clangd.llvm.org/installation.html#project-setup + """ + # get current working directory + cwd = os.getcwd() + # switch to repo directory + os.chdir(source_directory_path) + # set cmake to export compile commands + os.system('cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=1 -S . -B build') + # generate build + os.system('cmake --build build') + # create sym link to compile commands at the top level + os.system('ln -s build/compile_commands.json .') + # return back to prev working dir + os.chdir(cwd) + +@pytest.mark.asyncio +async def test_multilspy_clang(): + """ + Test the working of multilspy with cpp repository - https://github.com/tomorrowCoder/yaml-cpp + """ + code_language = Language.CPP + params = { + "code_language": code_language, + "repo_url": "https://github.com/jbeder/yaml-cpp/", + "repo_commit": "39f737443b05e4135e697cb91c2b7b18095acd53" + } + with create_test_context(params) as context: + # create compile commands file before starting language server + create_compile_commands_file(context.source_directory) + # create language server + lsp = LanguageServer.create(context.config, context.logger, context.source_directory) + + async with lsp.start_server(): + # get definition for create_node + result = await lsp.request_definition(str(PurePath("src/node_data.cpp")), 241, 26) + assert isinstance(result, list) + assert len(result) > 0 + assert result[0]["relativePath"] == str(PurePath("include/yaml-cpp/node/detail/memory.h")) + + # find references for WriteCodePoint + result = await lsp.request_references(str(PurePath("src/emitterutils.cpp")), 134, 6) + assert isinstance(result, list) + assert len(result) == 5 + + # get hover information for strFormat variable + result = await lsp.request_hover(str(PurePath("src/emitterutils.cpp")), 274, 11) + assert result is not None + + # get document symbols for binary.cpp + result = await lsp.request_document_symbols(str(PurePath("src/binary.cpp"))) + assert isinstance(result, tuple)