diff --git a/.gitignore b/.gitignore index c7cc380..9d6c6ec 100644 --- a/.gitignore +++ b/.gitignore @@ -408,3 +408,6 @@ src/multilspy/language_servers/clangd_language_server/static/ # Virtual Environment .venv/ venv/ + + +static/ \ No newline at end of file diff --git a/README.md b/README.md index 98a9832..e3acb87 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,8 @@ pip install multilspy | dart | Dart | | ruby | Solargraph | | kotlin | KotlinLanguageServer | +| php | Intelephense | +| cpp | clangd | ## Usage @@ -58,7 +60,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" +config = MultilspyConfig.from_dict({"code_language": "java"}) # Also supports "python", "rust", "csharp", "typescript", "javascript", "go", "dart", "ruby", "kotlin", "php" 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 bd05b27..3d2cf57 100644 --- a/src/multilspy/language_server.py +++ b/src/multilspy/language_server.py @@ -122,6 +122,11 @@ 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.PHP: + from multilspy.language_servers.intelephense.intelephense import Intelephense + + return Intelephense(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/intelephense/initialize_params.json b/src/multilspy/language_servers/intelephense/initialize_params.json new file mode 100644 index 0000000..3f8c8e3 --- /dev/null +++ b/src/multilspy/language_servers/intelephense/initialize_params.json @@ -0,0 +1,13 @@ +{ + "_description": "This file contains the initialization parameters for the Dart Language Server.", + "processId": "$processId", + "rootPath": "$rootPath", + "rootUri": "$rootUri", + "capabilities": {}, + "workspaceFolders": [ + { + "uri": "$uri", + "name": "$name" + } + ] +} \ No newline at end of file diff --git a/src/multilspy/language_servers/intelephense/intelephense.py b/src/multilspy/language_servers/intelephense/intelephense.py new file mode 100644 index 0000000..06ef3da --- /dev/null +++ b/src/multilspy/language_servers/intelephense/intelephense.py @@ -0,0 +1,165 @@ +from contextlib import asynccontextmanager +import logging +import os +import pathlib +import pwd +import shutil +import stat +import subprocess +from typing import AsyncIterator +from multilspy.language_server import LanguageServer +from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo +import json +from multilspy.multilspy_utils import FileUtils, PlatformUtils + + +class Intelephense(LanguageServer): + """ + Provides Php specific instantiation of the LanguageServer class. + """ + + def __init__(self, config, logger, repository_root_path): + """ + Creates an Intelephense instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. + """ + + executable_path = self.setup_runtime_dependencies(logger) + super().__init__( + config, + logger, + repository_root_path, + ProcessLaunchInfo(cmd=executable_path, cwd=repository_root_path), + "php", + ) + + def setup_runtime_dependencies(self, logger: "MultilspyLogger") -> str: + with open( + os.path.join(os.path.dirname(__file__), "runtime_dependencies.json"), "r" + ) as f: + d = json.load(f) + del d["_description"] + + runtime_dependencies = d.get("runtimeDependencies", []) + php_ls_dir = os.path.join(os.path.dirname(__file__), "static", "intelephense") + + is_node_installed = shutil.which("node") is not None + assert ( + is_node_installed + ), "node is not installed or isn't in PATH. Please install NodeJS and try again." + is_npm_installed = shutil.which("npm") is not None + assert ( + is_npm_installed + ), "npm is not installed or isn't in PATH. Please install npm and try again." + + if not os.path.exists(php_ls_dir): + os.makedirs(php_ls_dir, exist_ok=True) + for dependency in runtime_dependencies: + user = pwd.getpwuid(os.getuid()).pw_name + subprocess.run( + dependency["command"], + shell=True, + check=True, + user=user, + cwd=php_ls_dir, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + intelephense_executable_path = os.path.join( + php_ls_dir, "node_modules", ".bin", "intelephense" + ) + + assert os.path.exists(intelephense_executable_path) + os.chmod( + intelephense_executable_path, + os.stat(intelephense_executable_path).st_mode + | stat.S_IXUSR + | stat.S_IXGRP + | stat.S_IXOTH, + ) + + return f"{intelephense_executable_path} --stdio" + + def _get_initialize_params(self, repository_absolute_path: str): + """ + Returns the initialize params for the Php 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["Intelephense"]: + """ + Start the language server and yield when the server is ready. + """ + + async def execute_client_command_handler(params): + return [] + + async def do_nothing(params): + return + + async def check_experimental_status(params): + pass + + async def window_log_message(msg): + self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + + 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_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 intelephense server process", logging.INFO) + await self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + self.logger.log( + "Sending initialize request to php-language-server", + logging.DEBUG, + ) + init_response = await self.server.send.initialize(initialize_params) + self.logger.log( + f"Received initialize response from intelephense: {init_response}", + logging.INFO, + ) + + self.logger.log( + "Sending initialized notification to intelephense", + logging.INFO, + ) + + self.server.notify.initialized({}) + + yield self + + await self.server.shutdown() + await self.server.stop() diff --git a/src/multilspy/language_servers/intelephense/runtime_dependencies.json b/src/multilspy/language_servers/intelephense/runtime_dependencies.json new file mode 100644 index 0000000..ca986c1 --- /dev/null +++ b/src/multilspy/language_servers/intelephense/runtime_dependencies.json @@ -0,0 +1,10 @@ +{ + "_description": "Used to download the runtime dependencies for running Phpactor Language Server, downloaded from https://phpactor.readthedocs.io/", + "runtimeDependencies": [ + { + "id": "intelephense", + "description": "Intelephense PHP language server for Linux, macOS, and Windows. Both x64 and arm64 are supported.", + "command": "npm install --prefix ./ intelephense@1.14.4" + } + ] +} \ No newline at end of file diff --git a/src/multilspy/multilspy_config.py b/src/multilspy/multilspy_config.py index 86c6a6c..7fa948a 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" + PHP = "php" def __str__(self) -> str: return self.value diff --git a/tests/multilspy/test_multilspy_php.py b/tests/multilspy/test_multilspy_php.py new file mode 100644 index 0000000..a858110 --- /dev/null +++ b/tests/multilspy/test_multilspy_php.py @@ -0,0 +1,251 @@ +""" +This file contains tests for running the Intelephense Language Server. +""" + +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_php(): + """ + Test the working of multilspy with a PHP repository. + """ + code_language = Language.PHP + params = { + "code_language": code_language, + "repo_url": "https://github.com/phpactor/phpactor/", + "repo_commit": "bfc8a7040bed145a35fb9afee0ddd645297b9ed9", + } + + with create_test_context(params) as context: + lsp = LanguageServer.create( + context.config, context.logger, context.source_directory + ) + async with lsp.start_server(): + result = await lsp.request_definition( + str(PurePath("lib/ConfigLoader/Tests/TestCase.php")), + 13, + 16, + ) + + for item in result: + del item["uri"] + del item["absolutePath"] + + assert result == [ + { + "range": { + "start": {"line": 9, "character": 24}, + "end": {"line": 9, "character": 34}, + }, + "relativePath": "lib/ConfigLoader/Tests/TestCase.php", + } + ] + + result = await lsp.request_document_symbols( + str( + PurePath( + "lib/Extension/LanguageServerIndexer/Handler/WorkspaceSymbolHandler.php" + ) + ), + ) + + assert isinstance(result, tuple) + assert len(result) == 2 + + symbols = result[0] + + assert symbols == [ + { + "name": "Phpactor\\Extension\\LanguageServerIndexer\\Handler", + "kind": 3, + "range": { + "start": {"line": 2, "character": 0}, + "end": {"line": 2, "character": 59}, + }, + "selectionRange": { + "start": {"line": 2, "character": 10}, + "end": {"line": 2, "character": 58}, + }, + }, + { + "name": "WorkspaceSymbolHandler", + "kind": 5, + "range": { + "start": {"line": 12, "character": 0}, + "end": {"line": 43, "character": 1}, + }, + "selectionRange": { + "start": {"line": 12, "character": 6}, + "end": {"line": 12, "character": 28}, + }, + }, + { + "name": "$provider", + "kind": 7, + "range": { + "start": {"line": 14, "character": 36}, + "end": {"line": 14, "character": 45}, + }, + "selectionRange": { + "start": {"line": 14, "character": 36}, + "end": {"line": 14, "character": 45}, + }, + }, + { + "name": "__construct", + "kind": 9, + "range": { + "start": {"line": 16, "character": 4}, + "end": {"line": 19, "character": 5}, + }, + "selectionRange": { + "start": {"line": 16, "character": 20}, + "end": {"line": 16, "character": 31}, + }, + }, + { + "name": "$provider", + "kind": 13, + "range": { + "start": {"line": 16, "character": 32}, + "end": {"line": 16, "character": 65}, + }, + "selectionRange": { + "start": {"line": 16, "character": 56}, + "end": {"line": 16, "character": 65}, + }, + }, + { + "name": "methods", + "kind": 6, + "range": { + "start": {"line": 21, "character": 4}, + "end": {"line": 26, "character": 5}, + }, + "selectionRange": { + "start": {"line": 21, "character": 20}, + "end": {"line": 21, "character": 27}, + }, + }, + { + "name": "symbol", + "kind": 6, + "range": { + "start": {"line": 28, "character": 4}, + "end": {"line": 37, "character": 5}, + }, + "selectionRange": { + "start": {"line": 31, "character": 20}, + "end": {"line": 31, "character": 26}, + }, + }, + { + "name": "$params", + "kind": 13, + "range": { + "start": {"line": 32, "character": 8}, + "end": {"line": 32, "character": 37}, + }, + "selectionRange": { + "start": {"line": 32, "character": 30}, + "end": {"line": 32, "character": 37}, + }, + }, + { + "name": "Closure", + "kind": 12, + "range": { + "start": {"line": 34, "character": 25}, + "end": {"line": 36, "character": 9}, + }, + "selectionRange": { + "start": {"line": 34, "character": 25}, + "end": {"line": 36, "character": 9}, + }, + }, + { + "name": "$params", + "kind": 13, + "range": { + "start": {"line": 34, "character": 42}, + "end": {"line": 34, "character": 49}, + }, + "selectionRange": { + "start": {"line": 34, "character": 42}, + "end": {"line": 34, "character": 49}, + }, + }, + { + "name": "registerCapabiltiies", + "kind": 6, + "range": { + "start": {"line": 39, "character": 4}, + "end": {"line": 42, "character": 5}, + }, + "selectionRange": { + "start": {"line": 39, "character": 20}, + "end": {"line": 39, "character": 40}, + }, + }, + { + "name": "$capabilities", + "kind": 13, + "range": { + "start": {"line": 39, "character": 41}, + "end": {"line": 39, "character": 73}, + }, + "selectionRange": { + "start": {"line": 39, "character": 60}, + "end": {"line": 39, "character": 73}, + }, + }, + { + "name": "$capabilities", + "kind": 13, + "range": { + "start": {"line": 41, "character": 8}, + "end": {"line": 41, "character": 21}, + }, + "selectionRange": { + "start": {"line": 41, "character": 8}, + "end": {"line": 41, "character": 21}, + }, + }, + ] + + +@pytest.mark.asyncio +async def test_multilspy_php_multiple_references(): + """ + Test the working of multilspy with PHP Language Server + """ + code_language = Language.PHP + params = { + "code_language": code_language, + "repo_url": "https://github.com/myclabs/DeepCopy/", + "repo_commit": "1720ddd719e16cf0db4eb1c6eca108031636d46c", + } + with create_test_context(params) as context: + lsp = LanguageServer.create( + context.config, context.logger, context.source_directory + ) + + async with lsp.start_server(): + result = await lsp.request_references( + file_path=str(PurePath("src/DeepCopy/DeepCopy.php")), + line=27, + column=8, + ) + + """ + This should be greater than 0 but the language server is not finding + the references for some reason. + """ + assert len(result) > 0 \ No newline at end of file