From 638f452d0381d20eafdddc3461812467ce1de8f8 Mon Sep 17 00:00:00 2001 From: s5k <40916972+s5k@users.noreply.github.com> Date: Mon, 21 Apr 2025 21:51:34 +0700 Subject: [PATCH] Add PHP support with Intelephense LSP --- src/multilspy/language_server.py | 4 + .../intelephense/initialize_params.json | 36 ++++ .../intelephense/intelephense.py | 195 ++++++++++++++++++ .../intelephense/runtime_dependencies.json | 10 + src/multilspy/multilspy_config.py | 3 + 5 files changed, 248 insertions(+) create mode 100644 src/multilspy/language_servers/intelephense/initialize_params.json create mode 100644 src/multilspy/language_servers/intelephense/intelephense.py create mode 100644 src/multilspy/language_servers/intelephense/runtime_dependencies.json diff --git a/src/multilspy/language_server.py b/src/multilspy/language_server.py index f3ee19cb..ba636de7 100644 --- a/src/multilspy/language_server.py +++ b/src/multilspy/language_server.py @@ -188,6 +188,10 @@ class LanguageServer: 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 00000000..4330560e --- /dev/null +++ b/src/multilspy/language_servers/intelephense/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/intelephense/intelephense.py b/src/multilspy/language_servers/intelephense/intelephense.py new file mode 100644 index 00000000..4533d590 --- /dev/null +++ b/src/multilspy/language_servers/intelephense/intelephense.py @@ -0,0 +1,195 @@ +""" +Provides PHP specific instantiation of the LanguageServer class using Intelephense. +""" + +import asyncio +import json +import shutil +import logging +import os +import subprocess +import pathlib +import stat +from contextlib import asynccontextmanager +from typing import AsyncIterator + +from overrides import override + +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 PlatformUtils, PlatformId + +# Platform-specific imports +if os.name != 'nt': # Unix-like systems + import pwd +else: + # Dummy pwd module for Windows + class pwd: + @staticmethod + def getpwuid(uid): + return type('obj', (), {'pw_name': os.environ.get('USERNAME', 'unknown')})() + +class Intelephense(LanguageServer): + """ + Provides PHP specific instantiation of the LanguageServer class using Intelephense. + """ + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + # For PHP projects, we should ignore: + # - vendor: third-party dependencies managed by Composer + # - node_modules: if the project has JavaScript components + # - cache: commonly used for caching + return super().is_ignored_dirname(dirname) or dirname in ["node_modules", "vendor", "cache"] + + def setup_runtime_dependencies(self, logger: MultilspyLogger, config: MultilspyConfig) -> str: + """ + Setup runtime dependencies for Intelephense. + """ + platform_id = PlatformUtils.get_platform_id() + + valid_platforms = [ + PlatformId.LINUX_x64, + PlatformId.LINUX_arm64, + PlatformId.OSX, + PlatformId.OSX_x64, + PlatformId.OSX_arm64, + PlatformId.WIN_x64, + PlatformId.WIN_arm64, + ] + assert platform_id in valid_platforms, f"Platform {platform_id} is not supported for multilspy PHP at the moment" + + 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", []) + intelephense_ls_dir = os.path.join(os.path.dirname(__file__), "static", "php-lsp") + + # Verify both node and npm are installed + 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." + + # Install intelephense if not already installed + if not os.path.exists(intelephense_ls_dir): + os.makedirs(intelephense_ls_dir, exist_ok=True) + for dependency in runtime_dependencies: + # Windows doesn't support the 'user' parameter and doesn't have pwd module + if PlatformUtils.get_platform_id().value.startswith("win"): + subprocess.run( + dependency["command"], + shell=True, + check=True, + cwd=intelephense_ls_dir, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + else: + # On Unix-like systems, run as non-root user + user = pwd.getpwuid(os.getuid()).pw_name + subprocess.run( + dependency["command"], + shell=True, + check=True, + user=user, + cwd=intelephense_ls_dir, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + + intelephense_executable_path = os.path.join(intelephense_ls_dir, "node_modules", ".bin", "intelephense") + assert os.path.exists(intelephense_executable_path), "intelephense executable not found. Please install intelephense and try again." + + return f"{intelephense_executable_path} --stdio" + + def __init__(self, config: MultilspyConfig, logger: MultilspyLogger, repository_root_path: str): + # Setup runtime dependencies before initializing + intelephense_cmd = self.setup_runtime_dependencies(logger, config) + + super().__init__( + config, + logger, + repository_root_path, + ProcessLaunchInfo(cmd=intelephense_cmd, cwd=repository_root_path), + "php" + ) + self.server_ready = asyncio.Event() + self.request_id = 0 + + def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize params for the TypeScript 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 Intelephense 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 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 from LSP client to LSP server and awaiting response", + logging.INFO, + ) + init_response = await self.server.send.initialize(initialize_params) + self.logger.log( + "After sent initialize params", + logging.INFO, + ) + + # Verify server capabilities + assert "textDocumentSync" in init_response["capabilities"] + assert "completionProvider" in init_response["capabilities"] + assert "definitionProvider" in init_response["capabilities"] + + self.server.notify.initialized({}) + self.completions_available.set() + + # Intelephense server is typically ready immediately after initialization + self.server_ready.set() + await self.server_ready.wait() + + yield self + + await self.server.shutdown() + await self.server.stop() \ No newline at end of file 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 00000000..e3a6d7e1 --- /dev/null +++ b/src/multilspy/language_servers/intelephense/runtime_dependencies.json @@ -0,0 +1,10 @@ +{ + "_description": "Used to download the runtime dependencies for running intelephense. Obtained from https://www.npmjs.com/package/intelephense", + "runtimeDependencies": [ + { + "id": "intelephense", + "description": "Intelephense package for Linux, OSX, and Windows. Both x64 and arm64 are supported.", + "command": "npm install --prefix ./ intelephense@1.14.4" + } + ] +} diff --git a/src/multilspy/multilspy_config.py b/src/multilspy/multilspy_config.py index bf53750a..2b4f7ca4 100644 --- a/src/multilspy/multilspy_config.py +++ b/src/multilspy/multilspy_config.py @@ -37,6 +37,7 @@ class Language(str, Enum): RUBY = "ruby" DART = "dart" CPP = "cpp" + PHP = "php" def __str__(self) -> str: return self.value @@ -65,6 +66,8 @@ class Language(str, Enum): return FilenameMatcher("*.kt", "*.kts") case self.DART: return FilenameMatcher("*.dart") + case self.PHP: + return FilenameMatcher("*.php") case _: raise ValueError(f"Unhandled language: {self}")