mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-13 04:15:11 +00:00
add phpantom lsp for php (#1554)
This commit is contained in:
1 parent
52bbeefa6a
commit
91a18eb465
8 files changed
+577
-17
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
Reference in new issue
Block a user