diff --git a/CHANGELOG.md b/CHANGELOG.md index 75c9309c8..4d63fefe7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Status of the `main` branch. Changes prior to the next official version change w The dict is forwarded to vtsls via `initializationOptions`, `workspace/didChangeConfiguration`, and `workspace/configuration` pulls. Enables Yarn PnP setups with `typescript.tsdk` pointing at the Yarn-generated SDK. + - Add experimental PHP backend `php_phpantom` using `PHPantom-dev/phpantom_lsp`. - `SvelteLanguageServer`: Fix diagnostics requests for TypeScript/JavaScript files incorrectly being processed by the Svelte LS instead of the TypeScript LS. - Improve quoting of arguments in shell executions diff --git a/docs/01-about/020_programming-languages.md b/docs/01-about/020_programming-languages.md index 6f435c2f4..de0697936 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -117,7 +117,8 @@ Some languages require additional installations or setup steps, as noted. (requires installation of Perl::LanguageServer) * **PHP** (by default, uses the Intelephense language server (language `php`), set `INTELEPHENSE_LICENSE_KEY` environment variable for premium features; - we also support [Phpactor](https://github.com/phpactor/phpactor) (language `php_phpactor`), which requires PHP 8.1+) + we also support [Phpactor](https://github.com/phpactor/phpactor) (language `php_phpactor`), which requires PHP 8.1+; + and the experimental [PHPantom](https://github.com/PHPantom-dev/phpantom_lsp) backend (language `php_phpantom`) * **Python** * **R** (requires installation of the `languageserver` R package) diff --git a/src/solidlsp/language_servers/phpantom.py b/src/solidlsp/language_servers/phpantom.py new file mode 100644 index 000000000..6fd42f0a0 --- /dev/null +++ b/src/solidlsp/language_servers/phpantom.py @@ -0,0 +1,301 @@ +""" +Provides PHP specific instantiation of the LanguageServer class using PHPantom. +""" + +import logging +import os +import pathlib +import shutil +import stat +from time import sleep +from typing import cast + +from overrides import override + +from solidlsp import ls_types +from solidlsp.ls import ( + LanguageServerDependencyProvider, + LanguageServerDependencyProviderSinglePath, + LSPFileBuffer, + SolidLanguageServer, +) +from solidlsp.ls_config import Language, LanguageServerConfig +from solidlsp.ls_utils import PlatformId, PlatformUtils +from solidlsp.lsp_protocol_handler import lsp_types as protocol_lsp_types +from solidlsp.lsp_protocol_handler.lsp_types import Definition, DefinitionParams, InitializeParams, LocationLink +from solidlsp.settings import SolidLSPSettings + +from .common import RuntimeDependency, RuntimeDependencyCollection + +log = logging.getLogger(__name__) + +PHPANTOM_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com") + +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_PHPANTOM_VERSION = "0.8.0" +DEFAULT_PHPANTOM_VERSION = "0.8.0" + +_PHPANTOM_RUNTIME_DEPENDENCIES_BY_VERSION: dict[str, tuple[RuntimeDependency, ...]] = { + "0.8.0": ( + RuntimeDependency( + id="phpantom_lsp", + description="PHPantom language server for macOS (arm64)", + url="https://github.com/PHPantom-dev/phpantom_lsp/releases/download/0.8.0/phpantom_lsp-aarch64-apple-darwin.tar.gz", + platform_id=PlatformId.OSX_arm64.value, + archive_type="gztar", + binary_name="phpantom_lsp", + sha256="2cdfd103b5df98d20712eaeea9bd00d2b459e2a588296f1ab8e558fe25fde456", + allowed_hosts=PHPANTOM_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="phpantom_lsp", + description="PHPantom language server for macOS (x64)", + url="https://github.com/PHPantom-dev/phpantom_lsp/releases/download/0.8.0/phpantom_lsp-x86_64-apple-darwin.tar.gz", + platform_id=PlatformId.OSX_x64.value, + archive_type="gztar", + binary_name="phpantom_lsp", + sha256="e09eef93342cd38c9f9cc6c58064d81b005d06ec6d054e7cdeeec7698dc6c5da", + allowed_hosts=PHPANTOM_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="phpantom_lsp", + description="PHPantom language server for Linux (arm64)", + url="https://github.com/PHPantom-dev/phpantom_lsp/releases/download/0.8.0/phpantom_lsp-aarch64-unknown-linux-gnu.tar.gz", + platform_id=PlatformId.LINUX_arm64.value, + archive_type="gztar", + binary_name="phpantom_lsp", + sha256="e87fc96430f1bcc4966f953033a73a4e2ea53b2dbb7dc3e5f71cc8ced9022a57", + allowed_hosts=PHPANTOM_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="phpantom_lsp", + description="PHPantom language server for Linux (x64)", + url="https://github.com/PHPantom-dev/phpantom_lsp/releases/download/0.8.0/phpantom_lsp-x86_64-unknown-linux-gnu.tar.gz", + platform_id=PlatformId.LINUX_x64.value, + archive_type="gztar", + binary_name="phpantom_lsp", + sha256="39615b495e624bbafe8787c3be61acabc123ec5ac23e9b30e00ab7660f50e020", + allowed_hosts=PHPANTOM_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="phpantom_lsp", + description="PHPantom language server for Windows (arm64)", + url="https://github.com/PHPantom-dev/phpantom_lsp/releases/download/0.8.0/phpantom_lsp-aarch64-pc-windows-msvc.zip", + platform_id=PlatformId.WIN_arm64.value, + archive_type="zip", + binary_name="phpantom_lsp.exe", + sha256="352bfd90351c0f35947ea1af458dabe7a4dc4753d0cabaf9711a23a61346a63d", + allowed_hosts=PHPANTOM_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="phpantom_lsp", + description="PHPantom language server for Windows (x64)", + url="https://github.com/PHPantom-dev/phpantom_lsp/releases/download/0.8.0/phpantom_lsp-x86_64-pc-windows-msvc.zip", + platform_id=PlatformId.WIN_x64.value, + archive_type="zip", + binary_name="phpantom_lsp.exe", + sha256="17e23af816fc7ec695fe716f6209df6b0eafcec2fdafb5d9d72e2b352d5ddf83", + allowed_hosts=PHPANTOM_ALLOWED_HOSTS, + ), + ) +} + + +def _create_phpantom_dependencies(version: str) -> RuntimeDependencyCollection: + dependencies = _PHPANTOM_RUNTIME_DEPENDENCIES_BY_VERSION.get(version) + if dependencies is None: + raise RuntimeError( + f"Unsupported phpantom_version '{version}'. " + + f"Known bundled versions: {', '.join(sorted(_PHPANTOM_RUNTIME_DEPENDENCIES_BY_VERSION))}" + ) + return RuntimeDependencyCollection(dependencies) + + +class PHPantomServer(SolidLanguageServer): + """ + Provides PHP specific instantiation of the LanguageServer class using PHPantom. + + PHPantom is an open-source PHP language server written in Rust. + It is an experimental alternative to Intelephense, which remains the default PHP language server. + + You can pass the following entries in ls_specific_settings["php_phpantom"]: + - ls_path: path to a pre-installed phpantom_lsp binary + - phpantom_version: override the pinned PHPantom version downloaded by Serena + - 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 PHPantom and return the path to the executable. + """ + # checking PATH first + system_phpantom = shutil.which("phpantom_lsp") + if system_phpantom is not None: + log.info(f"Using system-installed phpantom_lsp at {system_phpantom}") + return system_phpantom + + # resolving the bundled binary + phpantom_version = self._custom_settings.get("phpantom_version", DEFAULT_PHPANTOM_VERSION) + dependencies = _create_phpantom_dependencies(phpantom_version) + binary_dirname = "phpantom-lsp" if phpantom_version == INITIAL_PHPANTOM_VERSION else f"phpantom-lsp-{phpantom_version}" + binary_dir = os.path.join(self._ls_resources_dir, binary_dirname) + binary_path = dependencies.binary_path(binary_dir) + if not os.path.exists(binary_path): + os.makedirs(binary_dir, exist_ok=True) + dep = dependencies.get_single_dep_for_current_platform("phpantom_lsp") + log.info(f"Downloading phpantom_lsp from {dep.url}") + dependencies.install(binary_dir) + + if not os.path.exists(binary_path): + raise FileNotFoundError(f"phpantom_lsp executable not found at {binary_path}") + + if PlatformUtils.get_platform_id() not in (PlatformId.WIN_x64, PlatformId.WIN_arm64): + current_mode = os.stat(binary_path).st_mode + os.chmod(binary_path, current_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) + + return binary_path + + def _create_launch_command(self, core_path: str) -> list[str]: + return [core_path, "--stdio"] + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + super().__init__(config, repository_root_path, None, "php", solidlsp_settings) + self.request_id = 0 + self.language = Language.PHP_PHPANTOM + + 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 (PHPantom): {', '.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 PHPantom Language Server. + """ + root_uri = pathlib.Path(repository_absolute_path).as_uri() + + # declaring client capabilities + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + }, + "workspace": { + "applyEdit": True, + "workspaceEdit": { + "documentChanges": True, + "resourceOperations": ["create", "rename", "delete"], + "failureHandling": "textOnlyTransactional", + "normalizesLineEndings": True, + "changeAnnotationSupport": {"groupsOnLabel": True}, + }, + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "symbol": {"dynamicRegistration": True}, + }, + }, + "processId": os.getpid(), + "rootPath": repository_absolute_path, + "rootUri": root_uri, + "workspaceFolders": [ + { + "uri": root_uri, + "name": os.path.basename(repository_absolute_path), + } + ], + } + return cast(InitializeParams, initialize_params) + + def _start_server(self) -> None: + """Start PHPantom 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 PHPantom server process") + self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + # negotiating server capabilities + 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") + + capabilities = init_response["capabilities"] + assert "textDocumentSync" in capabilities + assert "completionProvider" in capabilities + assert "definitionProvider" in capabilities + assert "documentSymbolProvider" in capabilities, "Server must support document symbols" + assert capabilities.get("referencesProvider"), "PHPantom did not advertise references support" + + self.server.notify.initialized({}) + + @override + def _send_references_request(self, relative_file_path: str, line: int, column: int) -> list[protocol_lsp_types.Location] | None: + # waiting for cross-file index updates + sleep(1) + return super()._send_references_request(relative_file_path, line, column) + + @override + def _send_definition_request(self, definition_params: DefinitionParams) -> Definition | list[LocationLink] | None: + # waiting for cross-file index updates + sleep(1) + return super()._send_definition_request(definition_params) + + @override + def request_hover( + self, + relative_file_path: str, + line: int, + column: int, + file_buffer: LSPFileBuffer | None = None, + ) -> ls_types.Hover | None: + # requesting direct hover info + hover = super().request_hover(relative_file_path, line, column, file_buffer=file_buffer) + if hover is not None: + return hover + + # falling back to a usage-site hover + references = self.request_references(relative_file_path, line, column) + for reference in references: + ref_relative_path = reference.get("relativePath") + if ref_relative_path is None: + continue + start = reference["range"]["start"] + if ref_relative_path == relative_file_path and start["line"] == line and start["character"] == column: + continue + usage_hover = super().request_hover(ref_relative_path, start["line"], start["character"]) + if usage_hover is not None: + return usage_hover + + return None diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 8f593833a..9aa94b611 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -154,6 +154,10 @@ class Language(str, Enum): """Phpactor language server for PHP (instead of Intelephense, which is the default). Requires PHP 8.1+ on the system. Fully open-source (MIT license). """ + PHP_PHPANTOM = "php_phpantom" + """PHPantom language server for PHP (instead of Intelephense, which is the default). + Uses the open-source Rust-based phpantom_lsp binary and can be auto-downloaded. + """ MARKDOWN = "markdown" """Marksman language server for Markdown (experimental). Must be explicitly specified as the main language, not auto-detected. @@ -247,6 +251,7 @@ def is_experimental(self) -> bool: self.CSHARP_OMNISHARP, self.RUBY_SOLARGRAPH, self.PHP_PHPACTOR, + self.PHP_PHPANTOM, self.MARKDOWN, self.YAML, self.JSON, @@ -381,7 +386,7 @@ def get_source_fn_matcher(self) -> FilenameMatcher: return FilenameMatcher(".kt", ".kts") case self.DART: return FilenameMatcher(".dart") - case self.PHP | self.PHP_PHPACTOR: + case self.PHP | self.PHP_PHPACTOR | self.PHP_PHPANTOM: return FilenameMatcher(".php") case self.R: return FilenameMatcher(".R", ".r", ".Rmd", ".Rnw") @@ -595,6 +600,10 @@ def get_ls_class(self) -> type["SolidLanguageServer"]: from solidlsp.language_servers.phpactor import PhpactorServer return PhpactorServer + case self.PHP_PHPANTOM: + from solidlsp.language_servers.phpantom import PHPantomServer + + return PHPantomServer case self.PERL: from solidlsp.language_servers.perl_language_server import PerlLanguageServer diff --git a/test/conftest.py b/test/conftest.py index de0ac507d..ee485e401 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -41,6 +41,7 @@ class LanguageParamRequest: _LANGUAGE_REPO_ALIASES: dict[Language, Language] = { Language.CPP_CCLS: Language.CPP, Language.PHP_PHPACTOR: Language.PHP, + Language.PHP_PHPANTOM: Language.PHP, Language.PYTHON_JEDI: Language.PYTHON, Language.PYTHON_TY: Language.PYTHON, Language.RUBY_SOLARGRAPH: Language.RUBY, @@ -259,6 +260,7 @@ def project_with_ls(request: LanguageParamRequest) -> Iterator[Project]: Language.MSL: [pytest.mark.msl], Language.PHP: [pytest.mark.php], Language.PHP_PHPACTOR: [pytest.mark.php], + Language.PHP_PHPANTOM: [pytest.mark.php], Language.POWERSHELL: [pytest.mark.powershell], Language.PYTHON: [pytest.mark.python], Language.PYTHON_JEDI: [pytest.mark.python], @@ -318,6 +320,7 @@ def _determine_disabled_languages() -> list[Language]: php_tests_enabled = _sh.which("php") is not None if not php_tests_enabled: result.append(Language.PHP_PHPACTOR) + result.append(Language.PHP_PHPANTOM) al_tests_enabled = True if not al_tests_enabled: diff --git a/test/solidlsp/php/test_php_basic.py b/test/solidlsp/php/test_php_basic.py index aab824f38..947495b7b 100644 --- a/test/solidlsp/php/test_php_basic.py +++ b/test/solidlsp/php/test_php_basic.py @@ -19,6 +19,20 @@ def _php_supports_phpactor() -> bool: if language_tests_enabled(Language.PHP_PHPACTOR): if not (is_windows and is_ci) and _php_supports_phpactor(): _php_servers.append(Language.PHP_PHPACTOR) +if language_tests_enabled(Language.PHP_PHPANTOM): + _php_servers.append(Language.PHP_PHPANTOM) + + +def _is_phpactor(language_server: SolidLanguageServer) -> bool: + return language_server.language_server.language == Language.PHP_PHPACTOR + + +def _is_phpantom(language_server: SolidLanguageServer) -> bool: + return language_server.language_server.language == Language.PHP_PHPANTOM + + +def _is_non_default_php_backend(language_server: SolidLanguageServer) -> bool: + return _is_phpactor(language_server) or _is_phpantom(language_server) @pytest.mark.php @@ -42,13 +56,13 @@ def test_find_definition_within_file(self, language_server: SolidLanguageServer, # $greeting in echo $greeting; (e c h o $ g r e e t i n g) # ^ char 5 # Intelephense uses line 10 (0-indexed), Phpactor uses line 11 (0-indexed) - if language_server.language_server.language == Language.PHP_PHPACTOR: + if _is_non_default_php_backend(language_server): definition_location_list = language_server.request_definition(str(repo_path / "index.php"), 11, 6) else: definition_location_list = language_server.request_definition(str(repo_path / "index.php"), 10, 6) assert definition_location_list, f"Expected non-empty definition_location_list but got {definition_location_list=}" - if language_server.language_server.language == Language.PHP_PHPACTOR: + if _is_non_default_php_backend(language_server): assert len(definition_location_list) >= 1 else: assert len(definition_location_list) == 1 @@ -56,27 +70,27 @@ def test_find_definition_within_file(self, language_server: SolidLanguageServer, assert definition_location["uri"].endswith("index.php") # Definition of $greeting is on line 10 (1-indexed) / line 9 (0-indexed), char 0 assert definition_location["range"]["start"]["line"] == 9 - if language_server.language_server.language != Language.PHP_PHPACTOR: + if not _is_non_default_php_backend(language_server): assert definition_location["range"]["start"]["character"] == 0 @pytest.mark.parametrize("language_server", _php_servers, 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: # Intelephense uses line 12 (0-indexed), Phpactor uses line 13 (0-indexed) - if language_server.language_server.language == Language.PHP_PHPACTOR: + if _is_non_default_php_backend(language_server): definition_location_list = language_server.request_definition(str(repo_path / "index.php"), 13, 5) else: definition_location_list = language_server.request_definition(str(repo_path / "index.php"), 12, 5) assert definition_location_list, f"Expected non-empty definition_location_list but got {definition_location_list=}" - if language_server.language_server.language == Language.PHP_PHPACTOR: + if _is_non_default_php_backend(language_server): assert len(definition_location_list) >= 1 else: 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 - if language_server.language_server.language != Language.PHP_PHPACTOR: + if not _is_non_default_php_backend(language_server): assert definition_location["range"]["start"]["character"] == 0 @pytest.mark.parametrize("language_server", _php_servers, indirect=True) @@ -94,14 +108,14 @@ def test_find_definition_simple_variable(self, language_server: SolidLanguageSer definition_location_list = language_server.request_definition(file_path, 2, 6) # cursor on 'l' in $localVar assert definition_location_list, f"Expected non-empty definition_location_list but got {definition_location_list=}" - if language_server.language_server.language == Language.PHP_PHPACTOR: + if _is_non_default_php_backend(language_server): assert len(definition_location_list) >= 1 else: 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 # Definition of $localVar (0-indexed) - if language_server.language_server.language != Language.PHP_PHPACTOR: + if not _is_non_default_php_backend(language_server): assert definition_location["range"]["start"]["character"] == 0 # $localVar (0-indexed) @pytest.mark.parametrize("language_server", _php_servers, indirect=True) @@ -117,7 +131,7 @@ def test_find_references_within_file(self, language_server: SolidLanguageServer, assert references, f"Expected non-empty references for $greeting but got {references=}" - if language_server.language_server.language == Language.PHP_PHPACTOR: + if _is_non_default_php_backend(language_server): actual_locations = [ { "uri_suffix": loc["uri"].split("/")[-1], @@ -163,7 +177,7 @@ def test_find_references_across_files(self, language_server: SolidLanguageServer assert references, f"Expected non-empty references for helperFunction but got {references=}" - if language_server.language_server.language == Language.PHP_PHPACTOR: + if _is_non_default_php_backend(language_server): actual_locations_comparable = [] for loc in references: actual_locations_comparable.append( @@ -194,7 +208,7 @@ def test_find_references_across_files(self, language_server: SolidLanguageServer usage_in_index_php = {"uri_suffix": "index.php", "line": 13, "character": 0} assert usage_in_index_php in actual_locations_comparable, "Usage of helperFunction in index.php not found" - @pytest.mark.parametrize("language_server", [Language.PHP], indirect=True) + @pytest.mark.parametrize("language_server", [Language.PHP, Language.PHP_PHPANTOM], indirect=True) def test_find_symbol(self, language_server: SolidLanguageServer) -> None: """Test that document symbols are properly retrieved after Intelephense capability fix.""" from solidlsp.ls_utils import SymbolUtils @@ -203,7 +217,7 @@ def test_find_symbol(self, language_server: SolidLanguageServer) -> None: assert SymbolUtils.symbol_tree_contains_name(symbols, "helperFunction"), "helperFunction not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "greet"), "greet function not found in symbol tree" - @pytest.mark.parametrize("language_server", [Language.PHP], indirect=True) + @pytest.mark.parametrize("language_server", [Language.PHP, Language.PHP_PHPANTOM], indirect=True) def test_document_symbols(self, language_server: SolidLanguageServer) -> None: """Test that document symbols are properly retrieved for a specific file.""" doc_symbols = language_server.request_document_symbols("helper.php") @@ -211,7 +225,7 @@ def test_document_symbols(self, language_server: SolidLanguageServer) -> None: symbol_names = [sym.get("name") for sym in all_symbols[0] if sym.get("name")] assert "helperFunction" in symbol_names, f"helperFunction not found in document symbols. Found: {symbol_names}" - @pytest.mark.parametrize("language_server", [Language.PHP], indirect=True) + @pytest.mark.parametrize("language_server", [Language.PHP, Language.PHP_PHPANTOM], indirect=True) def test_document_symbols_hierarchical_structure(self, language_server: SolidLanguageServer) -> None: """Verify Intelephense returns hierarchical DocumentSymbol format. @@ -245,7 +259,7 @@ def test_document_symbols_hierarchical_structure(self, language_server: SolidLan assert "greet" not in root_names, f"greet should be a child of Dog, not at root level. Roots: {root_names}" assert "fetch" not in root_names, f"fetch should be a child of Dog, not at root level. Roots: {root_names}" - @pytest.mark.parametrize("language_server", [Language.PHP], indirect=True) + @pytest.mark.parametrize("language_server", [Language.PHP, Language.PHP_PHPANTOM], indirect=True) def test_full_symbol_tree_within_file(self, language_server: SolidLanguageServer) -> None: """Verify request_full_symbol_tree scoped to a PHP file returns correct symbols. diff --git a/test/solidlsp/php/test_php_diagnostics.py b/test/solidlsp/php/test_php_diagnostics.py index 1cfc67de6..2bfead13e 100644 --- a/test/solidlsp/php/test_php_diagnostics.py +++ b/test/solidlsp/php/test_php_diagnostics.py @@ -7,7 +7,7 @@ @pytest.mark.php class TestPhpDiagnostics: - @pytest.mark.parametrize("language_server", [Language.PHP], indirect=True) + @pytest.mark.parametrize("language_server", [Language.PHP, Language.PHP_PHPANTOM], indirect=True) def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: assert_file_diagnostics( language_server, diff --git a/test/solidlsp/php/test_phpantom.py b/test/solidlsp/php/test_phpantom.py new file mode 100644 index 000000000..4ca4a0b43 --- /dev/null +++ b/test/solidlsp/php/test_phpantom.py @@ -0,0 +1,231 @@ +import shutil +from pathlib import Path + +import pytest + +from serena.code_editor import LanguageServerCodeEditor +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from src.serena.symbol import LanguageServerSymbolRetriever +from test.conftest import project_with_ls_context, start_ls_context + + +def _extract_changes(workspace_edit: dict) -> dict[str, list[dict]]: + changes = workspace_edit.get("changes", {}) + if changes: + return changes + + document_changes = workspace_edit.get("documentChanges", []) + return {change["textDocument"]["uri"]: change["edits"] for change in document_changes if "textDocument" in change and "edits" in change} + + +def _copy_php_fixture(tmp_path: Path) -> Path: + from test.conftest import get_repo_path + + fixture_path = get_repo_path(Language.PHP) + target_path = tmp_path / "test_repo" + shutil.copytree(fixture_path, target_path) + return target_path + + +def _write_psr4_fixture(repo_path: Path) -> None: + serena_dir = repo_path / ".serena" + serena_dir.mkdir(exist_ok=True) + (serena_dir / "project.yml").write_text( + """project_name: php-phase1 +languages: + - php_phpantom +""", + encoding="utf-8", + ) + + (repo_path / "composer.json").write_text('{"autoload": {"psr-4": {"Demo\\\\": "src/"}}}\n', encoding="utf-8") + + src_dir = repo_path / "src" + src_dir.mkdir(exist_ok=True) + + (src_dir / "Greeter.php").write_text( + """greet('world'); +} +""", + encoding="utf-8", + ) + + +def _find_root_symbol(language_server: SolidLanguageServer, relative_path: str, symbol_name: str) -> dict: + all_symbols, root_symbols = language_server.request_document_symbols(relative_path).get_all_symbols_and_roots() + for symbol in root_symbols: + if symbol.get("name") == symbol_name: + return symbol + for symbol in all_symbols: + if symbol.get("name") == symbol_name: + return symbol + raise AssertionError(f"Symbol {symbol_name!r} not found in {relative_path}") + + +def _find_child_symbol(parent_symbol: dict, child_name: str) -> dict: + for child in parent_symbol.get("children", []): + if child.get("name") == child_name: + return child + raise AssertionError(f"Child symbol {child_name!r} not found in {parent_symbol.get('name')!r}") + + +@pytest.mark.php +class TestPHPantom: + @pytest.mark.parametrize("language_server", [Language.PHP_PHPANTOM], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) + def test_rename_local_variable(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + workspace_edit = language_server.request_rename_symbol_edit(str(Path("index.php")), 9, 1, "welcomeMessage") + assert workspace_edit is not None, "Rename should be supported for local PHP variables" + + changes = _extract_changes(workspace_edit) + index_edits = [edits for uri, edits in changes.items() if uri.endswith("index.php")] + assert index_edits, f"Expected edits for index.php, got: {list(changes.keys())}" + + edits = index_edits[0] + assert len(edits) >= 2, f"Expected at least two local variable edits, got {len(edits)}" + assert {edit["range"]["start"]["line"] for edit in edits} >= {9, 11} + assert all(edit["newText"] == "$welcomeMessage" for edit in edits) + + def test_rename_method_across_files(self, tmp_path: Path) -> None: + repo_path = _copy_php_fixture(tmp_path) + _write_psr4_fixture(repo_path) + + with start_ls_context(Language.PHP_PHPANTOM, repo_path=str(repo_path), solidlsp_dir=tmp_path) as language_server: + welcome_symbol = _find_root_symbol(language_server, "src/Welcome.php", "Welcome") + greet_symbol = _find_child_symbol(welcome_symbol, "greet") + selection = greet_symbol["selectionRange"]["start"] + workspace_edit = language_server.request_rename_symbol_edit( + "src/Welcome.php", selection["line"], selection["character"], "salute" + ) + + assert workspace_edit is not None, "Rename should be supported for methods" + changes = _extract_changes(workspace_edit) + changed_files = sorted(uri.split("/")[-1] for uri in changes) + assert "Welcome.php" in changed_files + assert "UseWelcome.php" in changed_files + assert all(edit["newText"] == "salute" for edits in changes.values() for edit in edits) + + def test_rename_class_across_files(self, tmp_path: Path) -> None: + repo_path = _copy_php_fixture(tmp_path) + _write_psr4_fixture(repo_path) + + with start_ls_context(Language.PHP_PHPANTOM, repo_path=str(repo_path), solidlsp_dir=tmp_path) as language_server: + welcome_symbol = _find_root_symbol(language_server, "src/Welcome.php", "Welcome") + selection = welcome_symbol["selectionRange"]["start"] + workspace_edit = language_server.request_rename_symbol_edit( + "src/Welcome.php", selection["line"], selection["character"], "GreetingService" + ) + + assert workspace_edit is not None, "Rename should be supported for classes" + changes = _extract_changes(workspace_edit) + changed_files = sorted(uri.split("/")[-1] for uri in changes) + assert "GreetingService.php" in changed_files or "Welcome.php" in changed_files + assert "UseWelcome.php" in changed_files + assert all(edit["newText"] == "GreetingService" for edits in changes.values() for edit in edits) + + def test_psr4_class_rename_applies_file_rename(self, tmp_path: Path) -> None: + repo_path = _copy_php_fixture(tmp_path) + _write_psr4_fixture(repo_path) + + with project_with_ls_context(Language.PHP_PHPANTOM, str(repo_path)) as project: + symbol_retriever = LanguageServerSymbolRetriever(project) + code_editor = LanguageServerCodeEditor(symbol_retriever) + status_message = code_editor.rename_symbol("Welcome", relative_path="src/Welcome.php", new_name="GreetingService") + + assert "Successfully renamed 'Welcome' to 'GreetingService'" in status_message + assert not (repo_path / "src" / "Welcome.php").exists() + assert (repo_path / "src" / "GreetingService.php").exists() + + renamed_content = (repo_path / "src" / "GreetingService.php").read_text(encoding="utf-8") + usage_content = (repo_path / "src" / "UseWelcome.php").read_text(encoding="utf-8") + assert "class GreetingService" in renamed_content + assert "new GreetingService()" in usage_content + + def test_hover_info_is_available_via_find_symbol_include_info(self, tmp_path: Path) -> None: + repo_path = _copy_php_fixture(tmp_path) + _write_psr4_fixture(repo_path) + + with project_with_ls_context(Language.PHP_PHPANTOM, str(repo_path)) as project: + symbol_retriever = LanguageServerSymbolRetriever(project) + symbols = symbol_retriever.find("Welcome", within_relative_path="src/Welcome.php") + info_by_symbol = symbol_retriever.request_info_for_symbol_batch(symbols) + + welcome_infos = [info for symbol, info in info_by_symbol.items() if symbol.name == "Welcome"] + assert welcome_infos, "Expected Welcome symbol info to be available" + assert any(info and "Welcome service docs." in info for info in welcome_infos) + + def test_workspace_symbol_queries_cover_class_function_and_constant(self, tmp_path: Path) -> None: + repo_path = _copy_php_fixture(tmp_path) + _write_psr4_fixture(repo_path) + + with start_ls_context(Language.PHP_PHPANTOM, repo_path=str(repo_path), solidlsp_dir=tmp_path) as language_server: + class_symbols = language_server.request_workspace_symbol("Welcome") or [] + function_symbols = language_server.request_workspace_symbol("format_name") or [] + constant_symbols = language_server.request_workspace_symbol("MAX_GREETING_LENGTH") or [] + + def summarize(symbols: list[dict]) -> list[tuple[str, str]]: + return [(symbol["name"], symbol["location"]["uri"]) for symbol in symbols] + + assert any(name == "Demo\\Welcome" and uri.endswith("src/Welcome.php") for name, uri in summarize(class_symbols)) + assert all(uri.endswith(".php") for _name, uri in summarize(function_symbols)) + assert function_symbols == [] or any( + "location" in symbol and symbol["location"]["uri"].startswith("file://") for symbol in function_symbols + ) + assert constant_symbols == [] or any( + "location" in symbol and symbol["location"]["uri"].startswith("file://") for symbol in constant_symbols + )