c6af9e284a
Tests / catch-all (windows-latest) (push) Has been cancelled
Tests / jvm (macos-latest) (push) Has been cancelled
Tests / jvm (ubuntu-latest) (push) Has been cancelled
Tests / jvm (windows-latest) (push) Has been cancelled
Tests / native (macos-latest) (push) Has been cancelled
Tests / native (ubuntu-latest) (push) Has been cancelled
Tests / native (windows-latest) (push) Has been cancelled
Tests / niche (ubuntu-latest) (push) Has been cancelled
Tests / other-langs (macos-latest) (push) Has been cancelled
Tests / other-langs (ubuntu-latest) (push) Has been cancelled
Tests / other-langs (windows-latest) (push) Has been cancelled
Tests / catch-all (macos-latest) (push) Has been cancelled
Tests / catch-all (ubuntu-latest) (push) Has been cancelled
Docs Build / build (push) Has been cancelled
Docs Build / deploy (push) Has been cancelled
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Codespell / Check for spelling errors (push) Has been cancelled
Build and Push Docker Images / build-and-push (push) Has been cancelled
295 lines
16 KiB
Python
295 lines
16 KiB
Python
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from solidlsp import SolidLanguageServer
|
|
from solidlsp.ls_config import Language
|
|
from test.conftest import is_ci, is_windows, language_tests_enabled
|
|
from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols
|
|
|
|
|
|
def _php_supports_phpactor() -> bool:
|
|
completed_process = subprocess.run(["php", "-m"], capture_output=True, text=True, check=False)
|
|
installed_extensions = {line.strip().lower() for line in completed_process.stdout.splitlines()}
|
|
return "openssl" in installed_extensions
|
|
|
|
|
|
_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
|
|
class TestPhpLanguageServers:
|
|
@pytest.mark.parametrize("language_server", _php_servers, 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 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", _php_servers, 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:
|
|
# Line 9 (1-indexed): $greeting = greet($userName);
|
|
# Line 11 (1-indexed): echo $greeting;
|
|
# We want to find the definition of $greeting (defined on line 9)
|
|
# from its usage in echo $greeting; on line 11.
|
|
# LSP is 0-indexed: definition on line 8, usage on line 10.
|
|
# $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 _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 _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("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 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 _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 _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 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_simple_variable(self, language_server: SolidLanguageServer, repo_path: Path) -> None:
|
|
file_path = str(repo_path / "simple_var.php")
|
|
|
|
# In simple_var.php:
|
|
# Line 2 (1-indexed): $localVar = "test";
|
|
# Line 3 (1-indexed): echo $localVar;
|
|
# LSP is 0-indexed: definition on line 1, usage on line 2
|
|
# Find definition of $localVar (char 5 on line 3 / 0-indexed: line 2, char 5)
|
|
# $localVar in echo $localVar; (e c h o $ l o c a l V a r)
|
|
# ^ char 5
|
|
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 _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 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)
|
|
@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 in "echo $greeting;" (line 11, char 6 for 'g')
|
|
references = language_server.request_references(index_php_path, 11, 6)
|
|
|
|
assert references, f"Expected non-empty references for $greeting but got {references=}"
|
|
|
|
if _is_non_default_php_backend(language_server):
|
|
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}"
|
|
else:
|
|
# Intelephense, when asked for references from usage, seems to only return the usage itself.
|
|
assert len(references) == 1, "Expected to find 1 reference for $greeting (the usage itself)"
|
|
|
|
expected_locations = [{"uri_suffix": "index.php", "line": 11, "character": 5}] # Usage: echo $greeting (points to $)
|
|
|
|
# Convert actual references to a comparable format and sort
|
|
actual_locations = sorted(
|
|
[
|
|
{
|
|
"uri_suffix": loc["uri"].split("/")[-1],
|
|
"line": loc["range"]["start"]["line"],
|
|
"character": loc["range"]["start"]["character"],
|
|
}
|
|
for loc in references
|
|
],
|
|
key=lambda x: (x["uri_suffix"], x["line"], x["character"]),
|
|
)
|
|
|
|
expected_locations = sorted(expected_locations, key=lambda x: (x["uri_suffix"], x["line"], x["character"]))
|
|
|
|
assert actual_locations == expected_locations
|
|
|
|
@pytest.mark.parametrize("language_server", _php_servers, 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")
|
|
# In index.php (0-indexed lines):
|
|
# Line 13: helperFunction(); // Usage of helperFunction
|
|
# Find references for helperFunction from its definition
|
|
references = language_server.request_references(helper_php_path, 2, len("function "))
|
|
|
|
assert references, f"Expected non-empty references for helperFunction but got {references=}"
|
|
|
|
if _is_non_default_php_backend(language_server):
|
|
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}"
|
|
else:
|
|
# Intelephense might return 1 (usage) or 2 (usage + definition) references.
|
|
# Let's check for at least the usage in index.php
|
|
# Definition is in helper.php, line 2, char 0 (based on previous findings)
|
|
# Usage is in index.php, line 13, char 0
|
|
actual_locations_comparable = []
|
|
for loc in references:
|
|
actual_locations_comparable.append(
|
|
{
|
|
"uri_suffix": loc["uri"].split("/")[-1],
|
|
"line": loc["range"]["start"]["line"],
|
|
"character": loc["range"]["start"]["character"],
|
|
}
|
|
)
|
|
|
|
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, 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
|
|
|
|
symbols = language_server.request_full_symbol_tree()
|
|
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, 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")
|
|
all_symbols = doc_symbols.get_all_symbols_and_roots()
|
|
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, Language.PHP_PHPANTOM], indirect=True)
|
|
def test_document_symbols_hierarchical_structure(self, language_server: SolidLanguageServer) -> None:
|
|
"""Verify Intelephense returns hierarchical DocumentSymbol format.
|
|
|
|
When hierarchicalDocumentSymbolSupport is declared in client capabilities,
|
|
Intelephense returns DocumentSymbol[] where class methods appear as children
|
|
of their parent class. Without this declaration, it falls back to a flat
|
|
SymbolInformation[] list where all symbols appear at root level with no
|
|
parent-child relationships.
|
|
"""
|
|
all_symbols, root_symbols = language_server.request_document_symbols("sample.php").get_all_symbols_and_roots()
|
|
|
|
root_names = [s.get("name") for s in root_symbols]
|
|
assert "Animal" in root_names, f"Animal class not found at root level. Roots: {root_names}"
|
|
assert "Dog" in root_names, f"Dog class not found at root level. Roots: {root_names}"
|
|
assert "Cat" in root_names, f"Cat class not found at root level. Roots: {root_names}"
|
|
|
|
# Verify Dog has method children — this is the key assertion for hierarchical support.
|
|
# With a flat response, Dog would have no children and all methods would be at root level.
|
|
dog_symbol = next((s for s in root_symbols if s.get("name") == "Dog"), None)
|
|
assert dog_symbol is not None, "Dog class not found in root symbols"
|
|
dog_children = dog_symbol.get("children", [])
|
|
dog_child_names = [c.get("name") for c in dog_children]
|
|
assert len(dog_child_names) > 0, (
|
|
f"Dog class has no children — hierarchicalDocumentSymbolSupport is not working. All root symbols: {root_names}"
|
|
)
|
|
expected_methods = {"greet", "fetch", "getBreed", "describe"}
|
|
missing = expected_methods - set(dog_child_names)
|
|
assert not missing, f"Dog class missing expected methods: {missing}. Children found: {dog_child_names}"
|
|
|
|
# Methods must NOT appear at root level (that would indicate the flat fallback format).
|
|
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, 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.
|
|
|
|
This validates that Intelephense responds correctly when symbols are requested
|
|
for a single file, including class/method hierarchy in sample.php.
|
|
"""
|
|
from solidlsp.ls_utils import SymbolUtils
|
|
|
|
symbols = language_server.request_full_symbol_tree(within_relative_path="sample.php")
|
|
|
|
assert SymbolUtils.symbol_tree_contains_name(symbols, "Dog"), "Dog not found in sample.php symbol tree"
|
|
assert SymbolUtils.symbol_tree_contains_name(symbols, "Animal"), "Animal not found in sample.php symbol tree"
|
|
assert SymbolUtils.symbol_tree_contains_name(symbols, "greet"), "greet method not found in sample.php symbol tree"
|
|
assert SymbolUtils.symbol_tree_contains_name(symbols, "fetch"), "fetch method not found in sample.php symbol tree"
|
|
|
|
# Methods must appear as children of Dog, not as root-level symbols
|
|
dog_root = next((s for s in symbols if s.get("name") == "Dog"), None)
|
|
if dog_root is not None:
|
|
assert SymbolUtils.symbol_tree_contains_name([dog_root], "greet"), "greet should be nested under Dog in symbol tree"
|
|
|
|
@pytest.mark.parametrize("language_server", _php_servers, indirect=True)
|
|
def test_bare_symbol_names(self, language_server) -> None:
|
|
all_symbols = request_all_symbols(language_server)
|
|
malformed_symbols = []
|
|
for s in all_symbols:
|
|
if has_malformed_name(s):
|
|
malformed_symbols.append(s)
|
|
if malformed_symbols:
|
|
pytest.fail(
|
|
f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}",
|
|
pytrace=False,
|
|
)
|