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:
takeokunn committed 2026-02-07 22:40:56 +09:00
1 parent 944cf5d956
commit 65c24a0df8
7 files changed
+277 -7

No files matched your search

+1
View File
@@ -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
+2 -2
View File
@@ -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)
+4 -4
View File
@@ -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.)
+148
View File
@@ -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()
+10 -1
View File
@@ -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
View File
@@ -0,0 +1 @@
php
+111
View File
@@ -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}"