add phpantom lsp for php (#1554)

This commit is contained in:
Fabio Capucci authored and GitHub committed 2026-06-28 14:46:27 +02:00
1 parent 52bbeefa6a
commit 91a18eb465
8 files changed
+577 -17

No files matched your search

+1
View File
@@ -42,6 +42,7 @@ Status of the `main` branch. Changes prior to the next official version change w
- `Java`: invalidate JDTLS workspace cache when Java import settings change #1576
- Improve quoting of arguments in shell executions
- Add **LaTeX** support (experimental) via [texlab](https://github.com/latex-lsp/texlab).
- PHP: add support for PHPantom as alternative to the already supported PHP LS #1554.
* JetBrains:
- Add configuration option `jetbrains_launch_command`, allowing Serena to spawn IDE instances automatically
+2 -1
View File
@@ -122,7 +122,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)
+301
View File
@@ -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
+10 -1
View File
@@ -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.
@@ -252,6 +256,7 @@ class Language(str, Enum):
self.CSHARP_OMNISHARP,
self.RUBY_SOLARGRAPH,
self.PHP_PHPACTOR,
self.PHP_PHPANTOM,
self.MARKDOWN,
self.LATEX,
self.YAML,
@@ -387,7 +392,7 @@ class Language(str, Enum):
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")
@@ -603,6 +608,10 @@ class Language(str, Enum):
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
+3
View File
@@ -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,
@@ -260,6 +261,7 @@ _LANGUAGE_PYTEST_MARKERS: dict[Language, list[MarkDecorator | Mark]] = {
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],
@@ -319,6 +321,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:
+28 -14
View File
@@ -19,6 +19,20 @@ _php_servers: list[Language] = [Language.PHP]
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 @@ class TestPhpLanguageServers:
# $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 @@ class TestPhpLanguageServers:
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 @@ class TestPhpLanguageServers:
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 @@ class TestPhpLanguageServers:
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 @@ class TestPhpLanguageServers:
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 @@ class TestPhpLanguageServers:
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 @@ class TestPhpLanguageServers:
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 @@ class TestPhpLanguageServers:
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 @@ class TestPhpLanguageServers:
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.
+1 -1
View File
@@ -7,7 +7,7 @@ from test.solidlsp.util.diagnostics import assert_file_diagnostics
@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,
+231
View File
@@ -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(
"""<?php
namespace Demo;
interface Greeter
{
/** Returns a greeting for the given name. */
public function greet(string $name): string;
}
""",
encoding="utf-8",
)
(src_dir / "Helper.php").write_text(
"""<?php
namespace Demo;
/** Utility helper docs. */
function format_name(string $name): string
{
return strtoupper($name);
}
""",
encoding="utf-8",
)
(src_dir / "Welcome.php").write_text(
"""<?php
namespace Demo;
use const Demo\\MAX_GREETING_LENGTH;
use function Demo\\format_name;
const MAX_GREETING_LENGTH = 80;
/** Welcome service docs. */
class Welcome implements Greeter
{
/** Friendly greeting docs. */
public function greet(string $name): string
{
return substr("Hello ".format_name($name), 0, MAX_GREETING_LENGTH);
}
}
""",
encoding="utf-8",
)
(src_dir / "UseWelcome.php").write_text(
"""<?php
namespace Demo;
function run_demo(): string
{
$service = new Welcome();
return $service->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
)