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