mirror of
https://github.com/tiennm99/serena.git
synced 2026-09-02 18:20:05 +00:00
Merge pull request #119 from oraios/php-intelephense-lsp
Support for PHP, extended multi-platform testing
This commit is contained in:
@@ -1,13 +1,20 @@
|
||||
name: Tests on Ubuntu
|
||||
name: Tests on CI
|
||||
|
||||
on: [pull_request]
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
cpu:
|
||||
runs-on: ubuntu-latest
|
||||
name: Tests on ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
if: "!contains(github.event.head_commit.message, 'ci skip')"
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
python-version: ["3.11"]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
@@ -15,28 +22,39 @@ jobs:
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "${{ matrix.python-version }}"
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '>=1.17.0'
|
||||
# Add Go bin directory to PATH for this workflow
|
||||
# GITHUB_PATH is a special file that GitHub Actions uses to modify PATH
|
||||
# Writing to this file adds the directory to the PATH for subsequent steps
|
||||
- name: Install gopls
|
||||
run: |
|
||||
go install golang.org/x/tools/gopls@latest
|
||||
echo "$HOME/go/bin" >> $GITHUB_PATH
|
||||
|
||||
shell: bash
|
||||
run: go install golang.org/x/tools/gopls@latest
|
||||
- name: Install uv
|
||||
shell: bash
|
||||
run: curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
- name: Cache uv virtualenv
|
||||
id: cache-uv
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: .venv
|
||||
key: uv-venv-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('uv.lock') }}
|
||||
- name: Create virtual environment
|
||||
run: uv venv
|
||||
shell: bash
|
||||
run: |
|
||||
if [ ! -d ".venv" ]; then
|
||||
uv venv
|
||||
fi
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: uv pip install -e ".[dev]"
|
||||
- name: Test with pytest
|
||||
run: uv run poe test
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV }}
|
||||
file: ./coverage.xml
|
||||
flags: unittests
|
||||
name: codecov-umbrella
|
||||
fail_ci_if_error: false
|
||||
shell: bash
|
||||
# Currently java Language Server is not working on macos
|
||||
run: |
|
||||
if [ "${{ matrix.os }}" = "macos-latest" ]; then
|
||||
uv run pytest -m "not java"
|
||||
else
|
||||
uv run poe test
|
||||
fi
|
||||
|
||||
Vendored
-2
@@ -11,6 +11,4 @@
|
||||
"sensai",
|
||||
"vibing"
|
||||
],
|
||||
"basedpyright.disableLanguageServices": true,
|
||||
"python.languageServer": "None"
|
||||
}
|
||||
@@ -17,6 +17,7 @@ Changes prior to the next official version change will appear here.
|
||||
* Language Servers:
|
||||
* Add further file extensions considered by the language servers for Python (.pyi), JavaScript (.jsx) and TypeScript (.tsx, .jsx)
|
||||
* Updated multilspy, adding support for Kotlin, Dart and C/C++ and several improvements.
|
||||
* Added support for PhP
|
||||
|
||||
|
||||
# 2025-04-07
|
||||
|
||||
@@ -50,14 +50,16 @@ With Serena, we provide
|
||||
* Python
|
||||
* Java (_Note_: startup is slow, initial startup especially so)
|
||||
* TypeScript
|
||||
* PhP
|
||||
* Go (need to install go and gopls first)
|
||||
* Rust
|
||||
* C/C++
|
||||
* indirect support (may require some code changes/manual installation) for:
|
||||
* Ruby (untested)
|
||||
* Go (untested)
|
||||
* C# (untested)
|
||||
* Rust (untested)
|
||||
* Kotlin (untested)
|
||||
* Dart (untested)
|
||||
* C/C++ (untested)
|
||||
|
||||
These languages are supported by the language server library [multilspy](https://github.com/microsoft/multilspy), which Serena uses under the hood.
|
||||
But we did not explicitly test whether the support for these languages actually works.
|
||||
|
||||
+1
-24
@@ -51,7 +51,6 @@ dev = [
|
||||
"mypy>=1.4.1",
|
||||
"poethepoet>=0.20.0",
|
||||
"pytest>=8.0.2",
|
||||
"pytest-cov",
|
||||
"ruff>=0.0.285",
|
||||
"toml-sort>=0.24.2",
|
||||
"types-pyyaml>=6.0.12.20241230",
|
||||
@@ -116,27 +115,10 @@ lint = [
|
||||
"_black_check",
|
||||
"_ruff_check",
|
||||
]
|
||||
clean-nbs = "python docs/nbstripout.py"
|
||||
format = [
|
||||
"_ruff_format",
|
||||
"_black_format"
|
||||
]
|
||||
_autogen_rst = "python docs/autogen_rst.py"
|
||||
_sphinx_build = "sphinx-build -W -b html docs docs/_build"
|
||||
_jb_generate_toc = "python docs/create_toc.py"
|
||||
_jb_generate_config = "jupyter-book config sphinx docs/"
|
||||
doc-clean = "rm -rf docs/_build"
|
||||
doc-generate-files = [
|
||||
"_autogen_rst",
|
||||
"_jb_generate_toc",
|
||||
"_jb_generate_config"
|
||||
]
|
||||
doc-spellcheck = "sphinx-build -W -b spelling docs docs/_build"
|
||||
doc-build = [
|
||||
"doc-generate-files",
|
||||
"doc-spellcheck",
|
||||
"_sphinx_build"
|
||||
]
|
||||
_mypy = "mypy src/serena"
|
||||
type-check = [
|
||||
"_mypy",
|
||||
@@ -263,12 +245,6 @@ max-complexity = 20
|
||||
"tests/**" = [
|
||||
"D103"
|
||||
]
|
||||
"docs/**" = [
|
||||
"D103"
|
||||
]
|
||||
"examples/**" = [
|
||||
"D103"
|
||||
]
|
||||
"scripts/**" = [
|
||||
"D103"
|
||||
]
|
||||
@@ -280,4 +256,5 @@ markers = [
|
||||
"java: language server running for Java",
|
||||
"rust: language server running for Rust",
|
||||
"typescript: language server running for TypeScript",
|
||||
"php: language server running for PHP",
|
||||
]
|
||||
@@ -27,7 +27,7 @@ from serena.text_utils import LineType, MatchedConsecutiveLines, TextLine, searc
|
||||
from . import multilspy_types
|
||||
from .lsp_protocol_handler import lsp_types as LSPTypes
|
||||
from .lsp_protocol_handler.lsp_constants import LSPConstants
|
||||
from .lsp_protocol_handler.lsp_types import SymbolKind
|
||||
from .lsp_protocol_handler.lsp_types import Definition, DefinitionParams, LocationLink, SymbolKind
|
||||
from .lsp_protocol_handler.server import (
|
||||
Error,
|
||||
LanguageServerHandler,
|
||||
@@ -183,6 +183,10 @@ class LanguageServer:
|
||||
from multilspy.language_servers.clangd_language_server.clangd_language_server import ClangdLanguageServer
|
||||
|
||||
return ClangdLanguageServer(config, logger, repository_root_path)
|
||||
elif config.code_language == Language.PHP:
|
||||
from multilspy.language_servers.intelephense.intelephense import Intelephense
|
||||
|
||||
return Intelephense(config, logger, repository_root_path)
|
||||
else:
|
||||
logger.log(f"Language {config.code_language} is not supported", logging.ERROR)
|
||||
raise MultilspyException(f"Language {config.code_language} is not supported")
|
||||
@@ -491,6 +495,9 @@ class LanguageServer:
|
||||
)
|
||||
return deleted_text
|
||||
|
||||
async def _send_definition_request(self, definition_params: DefinitionParams) -> Union[Definition, List[LocationLink], None]:
|
||||
return await self.server.send.definition(definition_params)
|
||||
|
||||
async def request_definition(
|
||||
self, relative_file_path: str, line: int, column: int
|
||||
) -> List[multilspy_types.Location]:
|
||||
@@ -514,19 +521,18 @@ class LanguageServer:
|
||||
|
||||
with self.open_file(relative_file_path):
|
||||
# sending request to the language server and waiting for response
|
||||
response = await self.server.send.definition(
|
||||
{
|
||||
LSPConstants.TEXT_DOCUMENT: {
|
||||
LSPConstants.URI: pathlib.Path(
|
||||
str(PurePath(self.repository_root_path, relative_file_path))
|
||||
).as_uri()
|
||||
},
|
||||
LSPConstants.POSITION: {
|
||||
LSPConstants.LINE: line,
|
||||
LSPConstants.CHARACTER: column,
|
||||
},
|
||||
}
|
||||
)
|
||||
definition_params = cast(DefinitionParams, {
|
||||
LSPConstants.TEXT_DOCUMENT: {
|
||||
LSPConstants.URI: pathlib.Path(
|
||||
str(PurePath(self.repository_root_path, relative_file_path))
|
||||
).as_uri()
|
||||
},
|
||||
LSPConstants.POSITION: {
|
||||
LSPConstants.LINE: line,
|
||||
LSPConstants.CHARACTER: column,
|
||||
},
|
||||
})
|
||||
response = await self._send_definition_request(definition_params)
|
||||
|
||||
ret: List[multilspy_types.Location] = []
|
||||
if isinstance(response, list):
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"_description": "The parameters sent by the client when initializing the language server with the \"initialize\" request. More details at https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialize",
|
||||
"processId": "os.getpid()",
|
||||
"locale": "en",
|
||||
"rootPath": "$rootPath",
|
||||
"rootUri": "$rootUri",
|
||||
"capabilities": {
|
||||
"textDocument": {
|
||||
"synchronization": {
|
||||
"didSave": true,
|
||||
"dynamicRegistration": true
|
||||
},
|
||||
"completion": {
|
||||
"dynamicRegistration": true,
|
||||
"completionItem": {
|
||||
"snippetSupport": true
|
||||
}
|
||||
},
|
||||
"definition": {
|
||||
"dynamicRegistration": true
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
"workspaceFolders": true,
|
||||
"didChangeConfiguration": {
|
||||
"dynamicRegistration": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"workspaceFolders": [
|
||||
{
|
||||
"uri": "$uri",
|
||||
"name": "$name"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
"""
|
||||
Provides PHP specific instantiation of the LanguageServer class using Intelephense.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import shutil
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import pathlib
|
||||
from contextlib import asynccontextmanager
|
||||
from time import sleep
|
||||
from typing import AsyncIterator
|
||||
|
||||
from overrides import override
|
||||
|
||||
from multilspy.multilspy_logger import MultilspyLogger
|
||||
from multilspy.language_server import LanguageServer
|
||||
from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo
|
||||
from multilspy.lsp_protocol_handler.lsp_types import DefinitionParams, InitializeParams
|
||||
from multilspy.multilspy_config import MultilspyConfig
|
||||
from multilspy.multilspy_utils import PlatformUtils, PlatformId
|
||||
|
||||
class Intelephense(LanguageServer):
|
||||
"""
|
||||
Provides PHP specific instantiation of the LanguageServer class using Intelephense.
|
||||
"""
|
||||
|
||||
@override
|
||||
def is_ignored_dirname(self, dirname: str) -> bool:
|
||||
# For PHP projects, we should ignore:
|
||||
# - vendor: third-party dependencies managed by Composer
|
||||
# - node_modules: if the project has JavaScript components
|
||||
# - cache: commonly used for caching
|
||||
return super().is_ignored_dirname(dirname) or dirname in ["node_modules", "vendor", "cache"]
|
||||
|
||||
def setup_runtime_dependencies(self, logger: MultilspyLogger, config: MultilspyConfig) -> str:
|
||||
"""
|
||||
Setup runtime dependencies for Intelephense.
|
||||
"""
|
||||
platform_id = PlatformUtils.get_platform_id()
|
||||
|
||||
valid_platforms = [
|
||||
PlatformId.LINUX_x64,
|
||||
PlatformId.LINUX_arm64,
|
||||
PlatformId.OSX,
|
||||
PlatformId.OSX_x64,
|
||||
PlatformId.OSX_arm64,
|
||||
PlatformId.WIN_x64,
|
||||
PlatformId.WIN_arm64,
|
||||
]
|
||||
assert platform_id in valid_platforms, f"Platform {platform_id} is not supported for multilspy PHP at the moment"
|
||||
|
||||
with open(os.path.join(os.path.dirname(__file__), "runtime_dependencies.json"), "r") as f:
|
||||
d = json.load(f)
|
||||
del d["_description"]
|
||||
|
||||
runtime_dependencies = d.get("runtimeDependencies", [])
|
||||
intelephense_ls_dir = os.path.join(os.path.dirname(__file__), "static", "php-lsp")
|
||||
|
||||
# Verify both node and npm are installed
|
||||
is_node_installed = shutil.which('node') is not None
|
||||
assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again."
|
||||
is_npm_installed = shutil.which('npm') is not None
|
||||
assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again."
|
||||
|
||||
# Install intelephense if not already installed
|
||||
if not os.path.exists(intelephense_ls_dir):
|
||||
os.makedirs(intelephense_ls_dir, exist_ok=True)
|
||||
for dependency in runtime_dependencies:
|
||||
# Windows doesn't support the 'user' parameter and doesn't have pwd module
|
||||
if PlatformUtils.get_platform_id().value.startswith("win"):
|
||||
subprocess.run(
|
||||
dependency["command"],
|
||||
shell=True,
|
||||
check=True,
|
||||
cwd=intelephense_ls_dir,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL
|
||||
)
|
||||
else:
|
||||
# On Unix-like systems, run as non-root user
|
||||
import pwd
|
||||
user = pwd.getpwuid(os.getuid()).pw_name
|
||||
subprocess.run(
|
||||
dependency["command"],
|
||||
shell=True,
|
||||
check=True,
|
||||
user=user,
|
||||
cwd=intelephense_ls_dir,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL
|
||||
)
|
||||
|
||||
intelephense_executable_path = os.path.join(intelephense_ls_dir, "node_modules", ".bin", "intelephense")
|
||||
assert os.path.exists(intelephense_executable_path), "intelephense executable not found. Please install intelephense and try again."
|
||||
|
||||
return f"{intelephense_executable_path} --stdio"
|
||||
|
||||
def __init__(self, config: MultilspyConfig, logger: MultilspyLogger, repository_root_path: str):
|
||||
# Setup runtime dependencies before initializing
|
||||
intelephense_cmd = self.setup_runtime_dependencies(logger, config)
|
||||
|
||||
super().__init__(
|
||||
config,
|
||||
logger,
|
||||
repository_root_path,
|
||||
ProcessLaunchInfo(cmd=intelephense_cmd, cwd=repository_root_path),
|
||||
"php"
|
||||
)
|
||||
self.server_ready = asyncio.Event()
|
||||
self.request_id = 0
|
||||
|
||||
def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams:
|
||||
"""
|
||||
Returns the initialize params for the TypeScript Language Server.
|
||||
"""
|
||||
with open(os.path.join(os.path.dirname(__file__), "initialize_params.json"), "r") as f:
|
||||
d = json.load(f)
|
||||
|
||||
del d["_description"]
|
||||
|
||||
d["processId"] = os.getpid()
|
||||
assert d["rootPath"] == "$rootPath"
|
||||
d["rootPath"] = repository_absolute_path
|
||||
|
||||
assert d["rootUri"] == "$rootUri"
|
||||
d["rootUri"] = pathlib.Path(repository_absolute_path).as_uri()
|
||||
|
||||
assert d["workspaceFolders"][0]["uri"] == "$uri"
|
||||
d["workspaceFolders"][0]["uri"] = pathlib.Path(repository_absolute_path).as_uri()
|
||||
|
||||
assert d["workspaceFolders"][0]["name"] == "$name"
|
||||
d["workspaceFolders"][0]["name"] = os.path.basename(repository_absolute_path)
|
||||
|
||||
return d
|
||||
|
||||
@asynccontextmanager
|
||||
async def start_server(self) -> AsyncIterator["Intelephense"]:
|
||||
"""Start Intelephense server process"""
|
||||
async def register_capability_handler(params):
|
||||
return
|
||||
|
||||
async def window_log_message(msg):
|
||||
self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO)
|
||||
|
||||
async def do_nothing(params):
|
||||
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)
|
||||
|
||||
async with super().start_server():
|
||||
self.logger.log("Starting Intelephense server process", logging.INFO)
|
||||
await self.server.start()
|
||||
initialize_params = self._get_initialize_params(self.repository_root_path)
|
||||
|
||||
self.logger.log(
|
||||
"Sending initialize request from LSP client to LSP server and awaiting response",
|
||||
logging.INFO,
|
||||
)
|
||||
init_response = await self.server.send.initialize(initialize_params)
|
||||
self.logger.log(
|
||||
"After sent initialize params",
|
||||
logging.INFO,
|
||||
)
|
||||
|
||||
# Verify server capabilities
|
||||
assert "textDocumentSync" in init_response["capabilities"]
|
||||
assert "completionProvider" in init_response["capabilities"]
|
||||
assert "definitionProvider" in init_response["capabilities"]
|
||||
|
||||
self.server.notify.initialized({})
|
||||
self.completions_available.set()
|
||||
|
||||
# Intelephense server is typically ready immediately after initialization
|
||||
self.server_ready.set()
|
||||
await self.server_ready.wait()
|
||||
|
||||
yield self
|
||||
|
||||
await self.server.shutdown()
|
||||
await self.server.stop()
|
||||
|
||||
@override
|
||||
# For some reason, the LS may need longer to process this, so we just retry
|
||||
async def _send_references_request(self, relative_file_path: str, line: int, column: int):
|
||||
# TODO: The LS doesn't return references contained in other files if it doesn't sleep. This is
|
||||
# despite the LS having processed requests already. I don't know what causes this, but sleeping
|
||||
# one second helps. It may be that sleeping only once is enough but that's hard to reliably test.
|
||||
# May be related to the time it takes to read the files or something like that.
|
||||
# The sleeping doesn't seem to be needed on all systems
|
||||
sleep(1)
|
||||
return await super()._send_references_request(relative_file_path, line, column)
|
||||
|
||||
@override
|
||||
async def _send_definition_request(self, definition_params: DefinitionParams):
|
||||
# TODO: same as above, also only a problem if the definition is in another file
|
||||
sleep(1)
|
||||
return await super()._send_definition_request(definition_params)
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"_description": "Used to download the runtime dependencies for running intelephense. Obtained from https://www.npmjs.com/package/intelephense",
|
||||
"runtimeDependencies": [
|
||||
{
|
||||
"id": "intelephense",
|
||||
"description": "Intelephense package for Linux, OSX, and Windows. Both x64 and arm64 are supported.",
|
||||
"command": "npm install --prefix ./ intelephense@1.14.4"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -37,6 +37,7 @@ class Language(str, Enum):
|
||||
RUBY = "ruby"
|
||||
DART = "dart"
|
||||
CPP = "cpp"
|
||||
PHP = "php"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
@@ -65,6 +66,8 @@ class Language(str, Enum):
|
||||
return FilenameMatcher("*.kt", "*.kts")
|
||||
case self.DART:
|
||||
return FilenameMatcher("*.dart")
|
||||
case self.PHP:
|
||||
return FilenameMatcher("*.php")
|
||||
case _:
|
||||
raise ValueError(f"Unhandled language: {self}")
|
||||
|
||||
|
||||
+1
-1
@@ -81,7 +81,7 @@ class ProjectConfig(ToStringMixin):
|
||||
SERENA_DEFAULT_PROJECT_FILE = "project.yml"
|
||||
|
||||
@classmethod
|
||||
def rel_path_to_project_yml(cls):
|
||||
def rel_path_to_project_yml(cls) -> str:
|
||||
return os.path.join(cls.SERENA_MANAGED_DIR, cls.SERENA_DEFAULT_PROJECT_FILE)
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from multilspy.language_server import SyncLanguageServer
|
||||
from multilspy.multilspy_config import Language
|
||||
|
||||
|
||||
@pytest.mark.php
|
||||
class TestPhpLanguageServer:
|
||||
@pytest.mark.parametrize("language_server", [Language.PHP], indirect=True)
|
||||
@pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True)
|
||||
def test_ls_is_running(self, language_server: SyncLanguageServer, repo_path: Path) -> None:
|
||||
"""Test that the language server starts and stops successfully."""
|
||||
# The fixture already handles start and stop
|
||||
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], indirect=True)
|
||||
@pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True)
|
||||
def test_find_definition_within_file(self, language_server: SyncLanguageServer, 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; is at char 5 on line 11 (0-indexed: line 10, char 5)
|
||||
# e c h o $ g r e e t i n g
|
||||
# ^ char 5
|
||||
definition_location_list = language_server.request_definition(str(repo_path / "index.php"), 10, 6) # cursor on 'g' in $greeting
|
||||
|
||||
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")
|
||||
# Definition of $greeting is on line 10 (1-indexed) / line 9 (0-indexed), char 0
|
||||
assert definition_location["range"]["start"]["line"] == 9
|
||||
assert definition_location["range"]["start"]["character"] == 0
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.PHP], indirect=True)
|
||||
@pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True)
|
||||
def test_find_definition_across_files(self, language_server: SyncLanguageServer, repo_path: Path) -> None:
|
||||
definition_location_list = language_server.request_definition(str(repo_path / "index.php"), 12, 5) # helperFunction
|
||||
|
||||
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
|
||||
assert definition_location["range"]["start"]["character"] == 0
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.PHP], indirect=True)
|
||||
@pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True)
|
||||
def test_find_definition_simple_variable(self, language_server: SyncLanguageServer, 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=}"
|
||||
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)
|
||||
assert definition_location["range"]["start"]["character"] == 0 # $localVar (0-indexed)
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.PHP], indirect=True)
|
||||
@pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True)
|
||||
def test_find_references_within_file(self, language_server: SyncLanguageServer, 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
|
||||
# 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", [Language.PHP], indirect=True)
|
||||
@pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True)
|
||||
def test_find_references_across_files(self, language_server: SyncLanguageServer, repo_path: Path) -> None:
|
||||
index_php_path = str(repo_path / "index.php")
|
||||
# In index.php (0-indexed lines):
|
||||
# Line 13: helperFunction(); // Usage of helperFunction
|
||||
# Find references for helperFunction from its usage (line 13, char 0 for 'h')
|
||||
references = language_server.request_references(index_php_path, 13, 0)
|
||||
|
||||
assert references, f"Expected non-empty references for helperFunction but got {references=}"
|
||||
# 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}
|
||||
definition_in_helper_php = {"uri_suffix": "helper.php", "line": 2, "character": 0}
|
||||
|
||||
assert usage_in_index_php in actual_locations_comparable, "Usage of helperFunction in index.php not found"
|
||||
|
||||
# Depending on Intelephense's behavior, it might also include the definition.
|
||||
# For now, we are flexible: it must find the usage. If it finds more, that's also okay.
|
||||
# If we want to be strict about finding both or only one, this part would need adjustment.
|
||||
if len(references) == 2:
|
||||
assert (
|
||||
definition_in_helper_php in actual_locations_comparable
|
||||
), "Definition of helperFunction in helper.php expected but not found when 2 references returned"
|
||||
elif len(references) == 1:
|
||||
# If only one reference, ensure it's the usage we definitely expect
|
||||
assert actual_locations_comparable[0] == usage_in_index_php
|
||||
else:
|
||||
assert False, f"Expected 1 or 2 references, but got {len(references)}"
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
function helperFunction(): void {
|
||||
echo "Helper function was called.";
|
||||
}
|
||||
|
||||
?>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
require_once 'helper.php';
|
||||
|
||||
function greet(string $name): string {
|
||||
return "Hello, " . $name . "!";
|
||||
}
|
||||
|
||||
$userName = "PHP User";
|
||||
$greeting = greet($userName);
|
||||
|
||||
echo $greeting;
|
||||
|
||||
helperFunction();
|
||||
|
||||
?>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
$localVar = "test";
|
||||
echo $localVar;
|
||||
?>
|
||||
@@ -47,10 +47,15 @@ class TestSerenaAgent:
|
||||
@pytest.mark.parametrize(
|
||||
"serena_agent,symbol_name,def_file,ref_file",
|
||||
[
|
||||
(Language.PYTHON, "User", "test_repo/models.py", "test_repo/services.py"),
|
||||
(Language.PYTHON, "User", os.path.join("test_repo", "models.py"), os.path.join("test_repo", "services.py")),
|
||||
(Language.GO, "Helper", "main.go", "main.go"),
|
||||
(Language.JAVA, "Model", "src/main/java/test_repo/Model.java", "src/main/java/test_repo/Main.java"),
|
||||
(Language.RUST, "add", "src/lib.rs", "src/main.rs"),
|
||||
(
|
||||
Language.JAVA,
|
||||
"Model",
|
||||
os.path.join("src", "main", "java", "test_repo", "Model.java"),
|
||||
os.path.join("src", "main", "java", "test_repo", "Main.java"),
|
||||
),
|
||||
(Language.RUST, "add", os.path.join("src", "lib.rs"), os.path.join("src", "main.rs")),
|
||||
(Language.TYPESCRIPT, "helperFunction", "index.ts", "use_helper.ts"),
|
||||
],
|
||||
indirect=["serena_agent"],
|
||||
|
||||
Reference in New Issue
Block a user