Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/serena/ls_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,12 @@ def _get_suitable_language_server(self, relative_path: str) -> SolidLanguageServ
return None

def get_language_server(self, relative_path: str) -> SolidLanguageServer:
""":param relative_path: relative path to a file"""
"""Routes a file to the appropriate language server based on file extension.

When multiple language servers are configured, finds the first one whose
language supports the given file's extension. Falls back to the default
language server if no match is found.
"""
ls: SolidLanguageServer | None = None
if len(self._language_servers) > 1:
if os.path.isdir(relative_path):
Expand Down
69 changes: 66 additions & 3 deletions src/solidlsp/language_servers/intelephense.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,17 @@

from overrides import override

from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer
from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, LSPFileBuffer, SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.ls_utils import PlatformId, PlatformUtils
from solidlsp.lsp_protocol_handler.lsp_types import Definition, DefinitionParams, InitializeParams, LocationLink
from solidlsp.lsp_protocol_handler.lsp_types import (
Definition,
DefinitionParams,
DocumentSymbol,
InitializeParams,
LocationLink,
SymbolInformation,
)
from solidlsp.settings import SolidLSPSettings

from ..lsp_protocol_handler import lsp_types
Expand Down Expand Up @@ -95,7 +102,7 @@ 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)
super().__init__(config, repository_root_path, None, "php", solidlsp_settings, cache_version_raw_document_symbols=2)
self.request_id = 0

# For PHP projects, we should ignore:
Expand Down Expand Up @@ -214,3 +221,59 @@ def _send_definition_request(self, definition_params: DefinitionParams) -> Defin
# TODO: same as above, also only a problem if the definition is in another file
sleep(1)
return super()._send_definition_request(definition_params)

@override
def _request_document_symbols(
self, relative_file_path: str, file_data: LSPFileBuffer | None
) -> list[SymbolInformation] | list[DocumentSymbol] | None:
result = super()._request_document_symbols(relative_file_path, file_data)
if not result:
return result
# Intelephense can return flat SymbolInformation[] (no parent-child links) instead of
# DocumentSymbol[] for some PHP files. SymbolInformation items carry a top-level
# "location" key (with "uri"), whereas DocumentSymbol items have "range" at the top level.
if "location" in result[0]:
return self._reconstruct_document_symbols(result) # type: ignore
return result

@staticmethod
def _reconstruct_document_symbols(flat: list[SymbolInformation]) -> list[DocumentSymbol]:
"""Convert flat SymbolInformation[] to DocumentSymbol[] using containerName.

Intelephense sometimes returns the older SymbolInformation format for PHP files.
These symbols have no parent-child links, but carry a containerName that names
the enclosing class or function. This method reconstructs the proper hierarchy
so the rest of the codebase can treat the result as standard DocumentSymbol[].
"""
by_name: dict[str, DocumentSymbol] = {}
roots: list[DocumentSymbol] = []
converted: list[tuple[SymbolInformation, DocumentSymbol]] = []

for sym in flat:
rng = sym["location"]["range"]
doc_sym: DocumentSymbol = {
"name": sym["name"],
"kind": sym["kind"],
"range": rng,
"selectionRange": rng,
"children": [], # type: ignore[typeddict-unknown-key]
}
converted.append((sym, doc_sym))

# First pass: root symbols (no containerName)
for sym, doc_sym in converted:
if not sym.get("containerName"):
roots.append(doc_sym)
by_name[sym["name"]] = doc_sym

# Second pass: attach children to their container
for sym, doc_sym in converted:
container = sym.get("containerName")
if container:
if container in by_name:
by_name[container]["children"].append(doc_sym) # type: ignore[typeddict-item]
else:
# Container not found in this file; treat as root
roots.append(doc_sym)

return roots
11 changes: 6 additions & 5 deletions src/solidlsp/ls.py
Original file line number Diff line number Diff line change
Expand Up @@ -2046,11 +2046,12 @@ def request_full_symbol_tree(self, within_relative_path: str | None = None) -> l
raise FileNotFoundError(f"File or directory not found: {within_abs_path}")
if os.path.isfile(within_abs_path):
if self.is_ignored_path(within_relative_path):
log.error("You passed a file explicitly, but it is ignored. This is probably an error. File: %s", within_relative_path)
return []
else:
root_nodes = self.request_document_symbols(within_relative_path).root_symbols
return root_nodes
log.warning(
"File %s is ignored by ignore patterns but was explicitly requested; proceeding anyway.",
within_relative_path,
)
root_nodes = self.request_document_symbols(within_relative_path).root_symbols
return root_nodes

# Helper function to recursively process directories
def process_directory(rel_dir_path: str) -> list[ls_types.UnifiedSymbolInformation]:
Expand Down
91 changes: 91 additions & 0 deletions test/resources/repos/php/test_repo/class_example.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php

/**
* Example WordPress-style plugin class for testing Intelephense symbol retrieval.
*
* This file is used to test that find_symbol works correctly for class methods
* in PHP files, including both public and private static methods.
*
* @package TestRepo
*/

if ( ! defined( 'TEST_CONSTANT' ) ) {
define( 'TEST_CONSTANT', true );
}

/**
* A simple class with static methods, mirroring a WordPress webhook handler.
*/
class TestWebhookHandler {

/**
* Handle an incoming request.
*
* @param array $data Request data.
* @return bool
*/
public static function handle_request( array $data ): bool {
$parsed = self::parse_data( $data );
$profile_id = self::match_profile( $parsed['email'] );
return $profile_id > 0;
}

/**
* Parse raw data into a structured format.
*
* @param array $data Raw data.
* @return array Parsed data.
*/
private static function parse_data( array $data ): array {
return array(
'email' => $data['email'] ?? '',
'subject' => $data['subject'] ?? '',
'body' => $data['body'] ?? '',
);
}

/**
* Match a profile by email address.
*
* @param string $email Email address.
* @return int Profile ID, or 0 if not found.
*/
private static function match_profile( string $email ): int {
if ( empty( $email ) ) {
return 0;
}
return 42; // Simulated match.
}
}

/**
* A second class to test multi-class symbol retrieval.
*/
class TestProfileManager {

/** @var int */
private int $profile_id;

public function __construct( int $profile_id ) {
$this->profile_id = $profile_id;
}

/**
* Get the profile ID.
*
* @return int
*/
public function get_id(): int {
return $this->profile_id;
}

/**
* Update profile data.
*
* @param array $data Data to update.
* @return bool
*/
public function update( array $data ): bool {
return ! empty( $data );
}
}
85 changes: 85 additions & 0 deletions test/serena/test_symbol.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
from serena.jetbrains.jetbrains_types import SymbolDTO, SymbolDTOKey
from serena.project import Project
from serena.symbol import LanguageServerSymbol, LanguageServerSymbolRetriever, NamePathComponent, NamePathMatcher
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from solidlsp.ls_types import SymbolKind
from test.solidlsp.conftest import PYTHON_BACKEND_LANGUAGES


Expand Down Expand Up @@ -445,3 +448,85 @@ def counting_request_info(file_path, line, column, **kwargs):
# Global budget is 10s, all 3 should succeed
assert call_count == 3
assert all(info is not None for info in result.values())

<<<<<<< HEAD

def _make_flat_symbol(
name: str,
kind: SymbolKind,
container_name: str | None = None,
) -> dict:
"""Build a minimal UnifiedSymbolInformation dict as Intelephense returns for flat SymbolInformation[]."""
sym: dict = {
"name": name,
"kind": kind,
"location": {
"uri": "file:///dummy.php",
"range": {"start": {"line": 0, "character": 0}, "end": {"line": 10, "character": 0}},
"absolutePath": "/dummy.php",
"relativePath": "dummy.php",
},
"children": [],
"parent": None,
}
if container_name is not None:
sym["containerName"] = container_name
return sym


class TestLanguageServerSymbolFlatContainerName:
"""Unit tests for containerName fallback in LanguageServerSymbol.

Intelephense (and potentially other language servers) can return flat SymbolInformation[]
instead of hierarchical DocumentSymbol[] for some PHP files. In that case, class methods
appear as root-level symbols with no parent reference but carry a containerName field.
These tests verify that find_symbol("ClassName/method") still works in that scenario.
"""

def _make_flat_symbols(self) -> list[dict]:
return [
_make_flat_symbol("MyHandler", SymbolKind.Class),
_make_flat_symbol("handle_request", SymbolKind.Method, container_name="MyHandler"),
_make_flat_symbol("parse_data", SymbolKind.Method, container_name="MyHandler"),
_make_flat_symbol("MyManager", SymbolKind.Class),
_make_flat_symbol("get_id", SymbolKind.Method, container_name="MyManager"),
]

def test_find_by_simple_name_finds_method(self) -> None:
flat_symbols = self._make_flat_symbols()
found = []
for sym_dict in flat_symbols:
found.extend(LanguageServerSymbol(sym_dict).find("handle_request"))
assert len(found) == 1
assert found[0].name == "handle_request"

def test_find_by_class_method_pattern_finds_method(self) -> None:
flat_symbols = self._make_flat_symbols()
found = []
for sym_dict in flat_symbols:
found.extend(LanguageServerSymbol(sym_dict).find("MyHandler/handle_request"))
assert len(found) == 1, f"find('MyHandler/handle_request') should match via containerName. Got {[s.name for s in found]!r}"
assert found[0].name == "handle_request"

def test_find_by_class_method_does_not_match_wrong_container(self) -> None:
flat_symbols = self._make_flat_symbols()
found = []
for sym_dict in flat_symbols:
found.extend(LanguageServerSymbol(sym_dict).find("MyHandler/get_id"))
assert len(found) == 0

def test_find_private_method_by_class_method_pattern(self) -> None:
flat_symbols = self._make_flat_symbols()
found = []
for sym_dict in flat_symbols:
found.extend(LanguageServerSymbol(sym_dict).find("MyHandler/parse_data"))
assert len(found) == 1
assert found[0].name == "parse_data"

def test_find_in_second_class(self) -> None:
flat_symbols = self._make_flat_symbols()
found = []
for sym_dict in flat_symbols:
found.extend(LanguageServerSymbol(sym_dict).find("MyManager/get_id"))
assert len(found) == 1
assert found[0].name == "get_id"
Loading
Loading