diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a2fe6dc..b2cdfcca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ Status of the `main` branch. Changes prior to the next official version change w * Language support: + * **Add Phpactor as alternative PHP language server** (specify `php_phpactor` as language; requires PHP 8.1+) * **Add support for Fortran** via fortls language server (requires `pip install fortls`) * **Add partial support for Groovy** requires user-provided Groovy language server JAR (see [setup guide](docs/03-special-guides/groovy_setup_guide_for_serena.md)) * **Add support for Julia** via LanguageServer.jl diff --git a/docs/01-about/020_programming-languages.md b/docs/01-about/020_programming-languages.md index aa4a4834..c17fb0a6 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -64,8 +64,8 @@ Some languages require additional installations or setup steps, as noted. (Free Pascal/Lazarus; automatically downloads pasls binary; set PP and FPCDIR environment variables for source navigation) * **Perl** (requires installation of Perl::LanguageServer) -* **PHP** - (uses Intelephense LSP; set `INTELEPHENSE_LICENSE_KEY` environment variable for premium features) +* **PHP** + (by default, uses Intelephense LSP; set `INTELEPHENSE_LICENSE_KEY` environment variable for premium features; specify `php_phpactor` as your language to use the open-source [Phpactor](https://github.com/phpactor/phpactor) implementation, requires PHP 8.1+) * **Python** * **R** (requires installation of the `languageserver` R package) diff --git a/src/serena/resources/project.template.yml b/src/serena/resources/project.template.yml index 9472ba95..1ce23a99 100644 --- a/src/serena/resources/project.template.yml +++ b/src/serena/resources/project.template.yml @@ -8,10 +8,10 @@ project_name: "project_name" # fortran fsharp go groovy haskell # java julia kotlin lua markdown # matlab nix pascal perl php -# powershell python python_jedi r rego -# ruby ruby_solargraph rust scala swift -# terraform toml typescript typescript_vts vue -# yaml zig +# php_phpactor powershell python python_jedi r +# rego ruby ruby_solargraph rust scala +# swift terraform toml typescript typescript_vts +# vue yaml zig # (This list may be outdated. For the current list, see values of Language enum here: # https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py # For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) diff --git a/src/solidlsp/language_servers/phpactor.py b/src/solidlsp/language_servers/phpactor.py new file mode 100644 index 00000000..67dc4995 --- /dev/null +++ b/src/solidlsp/language_servers/phpactor.py @@ -0,0 +1,148 @@ +""" +Provides PHP specific instantiation of the LanguageServer class using Phpactor. +""" + +import logging +import os +import pathlib +import shutil +import stat + +from overrides import override + +from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer +from solidlsp.ls_config import Language, LanguageServerConfig +from solidlsp.ls_utils import FileUtils +from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + +PHPACTOR_VERSION = "2025.12.21.1" +PHPACTOR_PHAR_URL = f"https://github.com/phpactor/phpactor/releases/download/{PHPACTOR_VERSION}/phpactor.phar" + + +class PhpactorServer(SolidLanguageServer): + """ + Provides PHP specific instantiation of the LanguageServer class using Phpactor. + + Phpactor is an open-source (MIT) PHP language server that requires PHP 8.1+ on the system. + It is an alternative to Intelephense, which is the default PHP language server. + + You can pass the following entries in ls_specific_settings["php_phpactor"]: + - ignore_vendor: whether to ignore directories named "vendor" (default: true) + """ + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in self._ignored_dirnames + + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + def _get_or_install_core_dependency(self) -> str: + """ + Setup runtime dependencies for Phpactor and return the path to the PHAR file. + """ + # Verify PHP is installed + php_path = shutil.which("php") + assert ( + php_path is not None + ), "PHP is not installed or not found in PATH. Phpactor requires PHP 8.1+. Please install PHP and try again." + + phpactor_phar_path = os.path.join(self._ls_resources_dir, "phpactor.phar") + if not os.path.exists(phpactor_phar_path): + os.makedirs(self._ls_resources_dir, exist_ok=True) + log.info(f"Downloading phpactor PHAR from {PHPACTOR_PHAR_URL}") + FileUtils.download_and_extract_archive(PHPACTOR_PHAR_URL, phpactor_phar_path, "binary") + + assert os.path.exists(phpactor_phar_path), f"phpactor PHAR not found at {phpactor_phar_path}, download may have failed." + + # Ensure the PHAR is executable + current_mode = os.stat(phpactor_phar_path).st_mode + os.chmod(phpactor_phar_path, current_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) + + return phpactor_phar_path + + def _create_launch_command(self, core_path: str) -> list[str]: + return ["php", core_path, "language-server"] + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + super().__init__(config, repository_root_path, None, "php", solidlsp_settings) + # Override internal language enum for correct file matching + self.language = Language.PHP_PHPACTOR + + self._ignored_dirnames = {"node_modules", "cache"} + if self._custom_settings.get("ignore_vendor", True): + self._ignored_dirnames.add("vendor") + log.info(f"Ignoring the following directories for PHP (Phpactor): {', '.join(sorted(self._ignored_dirnames))}") + + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) + + def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialization params for the Phpactor Language Server. + """ + root_uri = pathlib.Path(repository_absolute_path).as_uri() + initialize_params = { + "processId": os.getpid(), + "rootPath": repository_absolute_path, + "rootUri": root_uri, + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "definition": {"dynamicRegistration": True}, + "documentSymbol": { + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + }, + }, + "workspaceFolders": [ + { + "uri": root_uri, + "name": os.path.basename(repository_absolute_path), + } + ], + "initializationOptions": { + "language_server_phpstan.enabled": False, + "language_server_psalm.enabled": False, + "language_server_php_cs_fixer.enabled": False, + }, + } + return initialize_params # type: ignore + + def _start_server(self) -> None: + """Start Phpactor server process.""" + + def register_capability_handler(params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + def do_nothing(params: dict) -> None: + 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) + + log.info("Starting Phpactor server process") + self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + log.info("After sent initialize params") + + # Verify server capabilities + assert "capabilities" in init_response + assert init_response["capabilities"].get("definitionProvider"), "Phpactor did not advertise definition support" + + self.server.notify.initialized({}) + self.completions_available.set() diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 04627009..37a9d79b 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -87,6 +87,10 @@ class Language(str, Enum): """Solargraph language server for Ruby (legacy, experimental). Use Language.RUBY (ruby-lsp) for better performance and modern LSP features. """ + PHP_PHPACTOR = "php_phpactor" + """Phpactor language server for PHP (instead of Intelephense, which is the default). + Requires PHP 8.1+ on the system. Fully open-source (MIT license). + """ MARKDOWN = "markdown" """Marksman language server for Markdown (experimental). Must be explicitly specified as the main language, not auto-detected. @@ -120,6 +124,7 @@ class Language(str, Enum): self.PYTHON_JEDI, self.CSHARP_OMNISHARP, self.RUBY_SOLARGRAPH, + self.PHP_PHPACTOR, self.MARKDOWN, self.YAML, self.TOML, @@ -176,7 +181,7 @@ class Language(str, Enum): return FilenameMatcher("*.kt", "*.kts") case self.DART: return FilenameMatcher("*.dart") - case self.PHP: + case self.PHP | self.PHP_PHPACTOR: return FilenameMatcher("*.php") case self.R: return FilenameMatcher("*.R", "*.r", "*.Rmd", "*.Rnw") @@ -308,6 +313,10 @@ class Language(str, Enum): from solidlsp.language_servers.intelephense import Intelephense return Intelephense + case self.PHP_PHPACTOR: + from solidlsp.language_servers.phpactor import PhpactorServer + + return PhpactorServer case self.PERL: from solidlsp.language_servers.perl_language_server import PerlLanguageServer diff --git a/test/resources/repos/php_phpactor b/test/resources/repos/php_phpactor new file mode 120000 index 00000000..fa557ad9 --- /dev/null +++ b/test/resources/repos/php_phpactor @@ -0,0 +1 @@ +php \ No newline at end of file diff --git a/test/solidlsp/php/test_php_phpactor.py b/test/solidlsp/php/test_php_phpactor.py new file mode 100644 index 00000000..65fab2f6 --- /dev/null +++ b/test/solidlsp/php/test_php_phpactor.py @@ -0,0 +1,111 @@ +from pathlib import Path + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language + + +@pytest.mark.php +class TestPhpPhpactorLanguageServer: + @pytest.mark.parametrize("language_server", [Language.PHP_PHPACTOR], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) + def test_ls_is_running(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that the phpactor language server starts and stops successfully.""" + assert language_server.is_running() + assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve() + + @pytest.mark.parametrize("language_server", [Language.PHP_PHPACTOR], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) + def test_find_definition_within_file(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + # In index.php (0-indexed lines): + # Line 9: $greeting = greet($userName); + # Line 11: echo $greeting; + # Find the definition of $greeting from its usage on line 11 (0-indexed), char 6 + definition_location_list = language_server.request_definition(str(repo_path / "index.php"), 11, 6) + + assert definition_location_list, f"Expected non-empty definition_location_list but got {definition_location_list=}" + assert len(definition_location_list) >= 1 + definition_location = definition_location_list[0] + assert definition_location["uri"].endswith("index.php") + assert definition_location["range"]["start"]["line"] == 9 + + @pytest.mark.parametrize("language_server", [Language.PHP_PHPACTOR], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) + def test_find_definition_across_files(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + # In index.php (0-indexed lines): + # Line 13: helperFunction(); + # Find the definition of helperFunction which is in helper.php, line 2 (0-indexed) + definition_location_list = language_server.request_definition(str(repo_path / "index.php"), 13, 5) + + assert definition_location_list, f"Expected non-empty definition_location_list but got {definition_location_list=}" + assert len(definition_location_list) >= 1 + definition_location = definition_location_list[0] + assert definition_location["uri"].endswith("helper.php") + assert definition_location["range"]["start"]["line"] == 2 + + @pytest.mark.parametrize("language_server", [Language.PHP_PHPACTOR], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) + def test_find_definition_simple_variable(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + file_path = str(repo_path / "simple_var.php") + + # In simple_var.php (0-indexed lines): + # Line 1: $localVar = "test"; + # Line 2: echo $localVar; + # Find definition of $localVar from its usage on line 2, char 6 + definition_location_list = language_server.request_definition(file_path, 2, 6) + + assert definition_location_list, f"Expected non-empty definition_location_list but got {definition_location_list=}" + assert len(definition_location_list) >= 1 + definition_location = definition_location_list[0] + assert definition_location["uri"].endswith("simple_var.php") + assert definition_location["range"]["start"]["line"] == 1 + + @pytest.mark.parametrize("language_server", [Language.PHP_PHPACTOR], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) + def test_find_references_within_file(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + index_php_path = str(repo_path / "index.php") + + # In index.php (0-indexed lines): + # Line 9: $greeting = greet($userName); // Definition of $greeting + # Line 11: echo $greeting; // Usage of $greeting + # Find references for $greeting from its usage on line 11, char 6 + references = language_server.request_references(index_php_path, 11, 6) + + assert references, f"Expected non-empty references for $greeting but got {references=}" + + actual_locations = [ + { + "uri_suffix": loc["uri"].split("/")[-1], + "line": loc["range"]["start"]["line"], + } + for loc in references + ] + + # Check that at least one reference points to $greeting usage in index.php + matching = [loc for loc in actual_locations if loc["uri_suffix"] == "index.php" and loc["line"] == 11] + assert matching, f"Expected reference to $greeting on line 11 of index.php, got {actual_locations}" + + @pytest.mark.parametrize("language_server", [Language.PHP_PHPACTOR], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) + def test_find_references_across_files(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + helper_php_path = str(repo_path / "helper.php") + + # Find references for helperFunction from its definition in helper.php + # Line 2 (0-indexed): function helperFunction(): void { + references = language_server.request_references(helper_php_path, 2, len("function ")) + + assert references, f"Expected non-empty references for helperFunction but got {references=}" + + actual_locations_comparable = [] + for loc in references: + actual_locations_comparable.append( + { + "uri_suffix": loc["uri"].split("/")[-1], + "line": loc["range"]["start"]["line"], + } + ) + + # Check that helperFunction usage in index.php line 13 is found + matching = [loc for loc in actual_locations_comparable if loc["uri_suffix"] == "index.php" and loc["line"] == 13] + assert matching, f"Usage of helperFunction in index.php (line 13) not found in {actual_locations_comparable}"