mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-14 18:19:14 +00:00
Merge branch 'main' of github.com:oraios/serena
This commit is contained in:
commit
9383bf788c
10 files changed
+388
-9
No files matched your search
@@ -18,6 +18,10 @@ Status of the `main` branch. Changes prior to the next official version change w
|
||||
* Language Servers:
|
||||
- Fix: Properly differentiate between raw and high-level symbol cache fingerprints, avoiding unnecessary
|
||||
invalidations of the raw cache when only the derived high-level representation changes
|
||||
- Fix: Language servers were not notified of external file system changes, causing some
|
||||
symbolic operations (such as `find_referencing_symbols`) to report stale information.
|
||||
An explicit file system polling mechanism is now used to detect changes prior to the affected
|
||||
tool executions.
|
||||
- Improve uv-based language server launch command compatibility: Use the more widely supported
|
||||
`uv tool run` instead of `uv x` #1721
|
||||
- Java (JDT-LS): add `runtimes` to `ls_specific_settings.java`, a list of extra JRE/JDK entries
|
||||
@@ -42,6 +46,12 @@ Status of the `main` branch. Changes prior to the next official version change w
|
||||
it fits, otherwise truncated with a trailing '...' and a note) before falling back to bare line
|
||||
numbers, so agents can pick the right match without re-reading files. #1640
|
||||
|
||||
* Language Servers:
|
||||
- PHP: treat `.phtml` files as PHP sources by default (all PHP language servers) #1710
|
||||
- PHP/Intelephense: expose `file_filter` via `ls_specific_settings["php"]`, so that additional
|
||||
extensions containing PHP sources (e.g. Drupal's `.module` / `.install` / `.inc` / `.theme`)
|
||||
become visible to the symbol tools and are indexed by the language server #1710
|
||||
|
||||
* JetBrains:
|
||||
- Allow external files from dependencies (specified via references like "<ext:FileUtil.class|472e0a13>") to be
|
||||
- read via `ReadFileTool`
|
||||
|
||||
@@ -931,6 +931,19 @@ Supported settings:
|
||||
| `ignore_vendor` | `true` | Ignore directories named `vendor` while indexing the project. |
|
||||
| `maxFileSize` | unset | Forwarded as `intelephense.files.maxSize` in `initializationOptions`. |
|
||||
| `maxMemory` | unset | Forwarded as `intelephense.maxMemory` in `initializationOptions`. |
|
||||
| `file_filter` | unset | Additional file extensions (with leading dot) to treat as PHP sources, e.g. `[".module", ".install"]`; added to the defaults `.php` / `.phtml` (#1710). |
|
||||
|
||||
Example configuration making Drupal source files visible to the symbol tools:
|
||||
|
||||
```yaml
|
||||
ls_specific_settings:
|
||||
php:
|
||||
file_filter: [".module", ".install", ".inc", ".theme", ".profile", ".engine"]
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Extensions added via `file_filter` are synced into Serena's PHP source-file matcher and pushed to Intelephense as `intelephense.files.associations` globs at startup, so `find_symbol` and the language server treat the same files as PHP sources.
|
||||
- The matcher is reset on every language server activation, so one project's `file_filter` does not leak into another.
|
||||
|
||||
#### PHP (`Phpactor`)
|
||||
|
||||
|
||||
+113
-5
@@ -2,14 +2,20 @@ import logging
|
||||
import os.path
|
||||
import threading
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sensai.util.logging import LogTime
|
||||
|
||||
from serena.config.serena_config import ProjectConfig, SerenaPaths
|
||||
from solidlsp import SolidLanguageServer
|
||||
from solidlsp.ls_config import Language, LanguageServerConfig
|
||||
from solidlsp.lsp_protocol_handler.lsp_types import DidChangeWatchedFilesParams, FileChangeType, FileEvent
|
||||
from solidlsp.settings import SolidLSPSettings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .project import Project
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -68,9 +74,7 @@ class LanguageServerManager:
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
language_servers: dict[Language, SolidLanguageServer],
|
||||
language_server_factory: LanguageServerFactory | None = None,
|
||||
self, language_servers: dict[Language, SolidLanguageServer], language_server_factory: LanguageServerFactory, project: "Project"
|
||||
) -> None:
|
||||
"""
|
||||
:param language_servers: a mapping from language to language server; the servers are assumed to be already started.
|
||||
@@ -81,6 +85,7 @@ class LanguageServerManager:
|
||||
"""
|
||||
self._language_servers = language_servers
|
||||
self._language_server_factory = language_server_factory
|
||||
self._file_change_notifier = LanguageServerFileChangeNotifier(project, self)
|
||||
|
||||
@property
|
||||
def _default_language_server(self) -> SolidLanguageServer:
|
||||
@@ -89,13 +94,14 @@ class LanguageServerManager:
|
||||
return next(iter(self._language_servers.values()))
|
||||
|
||||
@staticmethod
|
||||
def from_languages(languages: list[Language], factory: LanguageServerFactory) -> "LanguageServerManager":
|
||||
def from_languages(languages: list[Language], factory: LanguageServerFactory, project: "Project") -> "LanguageServerManager":
|
||||
"""
|
||||
Creates a manager with language servers for the given languages using the given factory.
|
||||
The language servers are started in parallel threads.
|
||||
|
||||
:param languages: the languages for which to spawn language servers
|
||||
:param factory: the factory for language server creation
|
||||
:param project: the project for which the language servers are created
|
||||
:return: the instance
|
||||
"""
|
||||
|
||||
@@ -145,7 +151,7 @@ class LanguageServerManager:
|
||||
failure_messages = "\n".join([f"{lang.value}: {e}" for lang, e in exceptions.items()])
|
||||
raise LanguageServerManagerInitialisationError(f"Failed to start {len(exceptions)} language server(s):\n{failure_messages}")
|
||||
|
||||
return LanguageServerManager(language_servers, factory)
|
||||
return LanguageServerManager(language_servers, factory, project)
|
||||
|
||||
def _ensure_functional_ls(self, ls: SolidLanguageServer) -> SolidLanguageServer:
|
||||
if not ls.is_running():
|
||||
@@ -254,3 +260,105 @@ class LanguageServerManager:
|
||||
|
||||
def has_suitable_ls_for_file(self, relative_file_path: str) -> bool:
|
||||
return self._get_suitable_language_server(relative_file_path) is not None
|
||||
|
||||
def sync_file_system_changes(self) -> int:
|
||||
"""
|
||||
Polls the file system for changes to source files and notifies the language servers of any changes
|
||||
(particularly changes that happened outside of Serena's own file tools, which are not covered by
|
||||
the notifications sent by those tools/CodeEditor).
|
||||
|
||||
:return: the number of individual file change events detected (0 if nothing changed).
|
||||
"""
|
||||
log.info("Polling file system for changes to source files ...")
|
||||
num_changes = self._file_change_notifier.poll_and_notify()
|
||||
log.info(f"File system polling complete; {num_changes} change events sent to language servers.")
|
||||
return num_changes
|
||||
|
||||
|
||||
class LanguageServerFileChangeNotifier:
|
||||
"""
|
||||
Detects changes to source files on disk and notifies language servers of those changes.
|
||||
"""
|
||||
|
||||
def __init__(self, project: "Project", language_server_manager: LanguageServerManager) -> None:
|
||||
self._project = project
|
||||
self._language_server_manager = language_server_manager
|
||||
self._freshness_last_seen_mtimes: dict[str, float] | None = None
|
||||
self._freshness_lock = threading.Lock()
|
||||
|
||||
def poll_and_notify(self) -> int:
|
||||
"""
|
||||
Detects source files that were changed, created or deleted on disk since the last call
|
||||
and notifies every language server managed for this project via the LSP
|
||||
``workspace/didChangeWatchedFiles`` notification.
|
||||
|
||||
This exists because Serena's own file and symbol tools notify the language server inline
|
||||
(via didOpen/didChange/didClose) when they edit a file, but edits made through any other
|
||||
channel (another editor, a second agent, a git checkout, a build step) are otherwise
|
||||
invisible to a warm language server, causing symbolic queries to answer from a stale index.
|
||||
|
||||
The set of files considered is exactly the set Serena itself tracks (see
|
||||
:meth:`gather_source_files`), so no separate file-discovery logic has to be kept in sync.
|
||||
The dominant cost is the directory walk plus one ``os.stat`` per tracked file; this is
|
||||
intended to be called before symbolic tool invocations rather than on a timer.
|
||||
|
||||
:return: the number of change events sent (0 if nothing changed, if no language server is
|
||||
running yet, or on the first call, which only establishes the baseline).
|
||||
"""
|
||||
current: dict[str, float] = {}
|
||||
for rel_path in self._project.gather_source_files():
|
||||
try:
|
||||
current[rel_path] = os.stat(os.path.join(self._project.project_root, rel_path)).st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
# Read-diff-swap under the lock only; the filesystem walk above and the LSP notifications
|
||||
# below stay outside it so concurrent callers do not serialize on I/O.
|
||||
with self._freshness_lock:
|
||||
previous = self._freshness_last_seen_mtimes
|
||||
self._freshness_last_seen_mtimes = current
|
||||
|
||||
if previous is None:
|
||||
return 0
|
||||
|
||||
# compute the set of individual events (created, changed, deleted)
|
||||
events: list[tuple[str, FileChangeType]] = []
|
||||
for rel_path, mtime in current.items():
|
||||
prev_mtime = previous.get(rel_path)
|
||||
if prev_mtime is None:
|
||||
events.append((rel_path, FileChangeType.Created))
|
||||
elif mtime > prev_mtime:
|
||||
events.append((rel_path, FileChangeType.Changed))
|
||||
events.extend((rel_path, FileChangeType.Deleted) for rel_path in previous if rel_path not in current)
|
||||
|
||||
if not events:
|
||||
return 0
|
||||
|
||||
# create the change didChangeWatchedFiles notification
|
||||
changes: list[FileEvent] = [
|
||||
{"uri": Path(self._project.project_root, rel_path).resolve().as_uri(), "type": change_type} for rel_path, change_type in events
|
||||
]
|
||||
params: DidChangeWatchedFilesParams = {"changes": changes}
|
||||
created_paths = [rel_path for rel_path, change_type in events if change_type == FileChangeType.Created]
|
||||
|
||||
for ls in self._language_server_manager.iter_language_servers():
|
||||
# send the didChangeWatchedFiles notification to the language server
|
||||
try:
|
||||
ls.server.notify.did_change_watched_files(params)
|
||||
except Exception as e:
|
||||
log.error("Failed to notify language server of watched file changes", exc_info=e)
|
||||
|
||||
# A didChangeWatchedFiles(Created) notification alone is not enough for every backend
|
||||
# (observed with pyright) to fold a brand-new file into its cross-file reference graph;
|
||||
# an open/close cycle forces the parse+bind that Serena's own file tools trigger via
|
||||
# SolidLanguageServer.open_file().
|
||||
for rel_path in created_paths:
|
||||
if ls.is_ignored_path(rel_path, ignore_unsupported_files=True):
|
||||
continue
|
||||
try:
|
||||
with ls.open_file(rel_path):
|
||||
pass
|
||||
except Exception as e:
|
||||
log.error(f"Failed to refresh newly created file {rel_path!r} in language server", exc_info=e)
|
||||
|
||||
return len(events)
|
||||
@@ -493,7 +493,7 @@ class Project(ToStringMixin):
|
||||
ls_specific_settings=ls_specific_settings,
|
||||
trace_lsp_communication=self.serena_config.trace_lsp_communication,
|
||||
)
|
||||
self.language_server_manager = LanguageServerManager.from_languages(self.project_config.languages, factory)
|
||||
self.language_server_manager = LanguageServerManager.from_languages(self.project_config.languages, factory, self)
|
||||
return self.language_server_manager
|
||||
except Exception as e:
|
||||
self._language_server_manager_init_error = e
|
||||
@@ -557,6 +557,14 @@ class Project(ToStringMixin):
|
||||
log.info("Removing and stopping the language server for language %s ...", language.value)
|
||||
self.language_server_manager.remove_language_server(language)
|
||||
|
||||
def ls_sync_file_system_changes(self) -> int:
|
||||
"""
|
||||
Synchronizes file system changes with the project's associated language server(s), if applicable
|
||||
"""
|
||||
if self.language_server_manager:
|
||||
return self.language_server_manager.sync_file_system_changes()
|
||||
return 0
|
||||
|
||||
def shutdown(self, timeout: float = 2.0) -> None:
|
||||
if self.language_server_manager is not None:
|
||||
self.language_server_manager.stop_all(save_cache=True, timeout=timeout)
|
||||
|
||||
@@ -274,11 +274,13 @@ class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead):
|
||||
:param max_answer_chars: max result length; -1 for default
|
||||
:return: a list of JSON objects with the symbols referencing the requested symbol
|
||||
"""
|
||||
self.project.ls_sync_file_system_changes()
|
||||
|
||||
include_body = False # It is probably never a good idea to include the body of the referencing symbols
|
||||
parsed_include_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in include_kinds] if include_kinds else None
|
||||
parsed_exclude_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in exclude_kinds] if exclude_kinds else None
|
||||
symbol_retriever = self.create_language_server_symbol_retriever()
|
||||
|
||||
symbol_retriever = self.create_language_server_symbol_retriever()
|
||||
references_in_symbols = symbol_retriever.find_referencing_symbols(
|
||||
name_path,
|
||||
relative_file_path=relative_path,
|
||||
@@ -361,6 +363,8 @@ class FindImplementationsTool(Tool, ToolMarkerSymbolicRead):
|
||||
:param max_answer_chars: max result length; -1 for default
|
||||
:return: a list of JSON objects with the symbols implementing the requested symbol
|
||||
"""
|
||||
self.project.ls_sync_file_system_changes()
|
||||
|
||||
include_body = False
|
||||
parsed_include_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in include_kinds] if include_kinds else None
|
||||
parsed_exclude_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in exclude_kinds] if exclude_kinds else None
|
||||
@@ -414,6 +418,8 @@ class FindDeclarationTool(Tool, ToolMarkerSymbolicRead):
|
||||
:param include_body: whether to include the symbol's body in the result. Default False.
|
||||
:param include_info: whether to include additional info (hover-like). Default False.
|
||||
"""
|
||||
self.project.ls_sync_file_system_changes()
|
||||
|
||||
symbol_retriever = self.create_language_server_symbol_retriever()
|
||||
relative_path = self._sanitize_input_param(relative_path)
|
||||
regex = self._sanitize_input_param(regex)
|
||||
@@ -496,6 +502,8 @@ class GetDiagnosticsForFileTool(Tool, ToolMarkerSymbolicRead):
|
||||
:param max_answer_chars: max result length; -1 for default
|
||||
:return: grouped diagnostics for the requested file.
|
||||
"""
|
||||
self.project.ls_sync_file_system_changes()
|
||||
|
||||
symbol_retriever = self.create_language_server_symbol_retriever()
|
||||
diagnostics = symbol_retriever.get_file_diagnostics(
|
||||
relative_file_path=relative_path,
|
||||
@@ -547,6 +555,8 @@ class GetDiagnosticsForSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOption
|
||||
:param max_answer_chars: max result length; -1 for default
|
||||
:return: grouped diagnostics for the requested symbol and, optionally, its referencing symbols.
|
||||
"""
|
||||
self.project.ls_sync_file_system_changes()
|
||||
|
||||
symbol_retriever = self.create_language_server_symbol_retriever()
|
||||
diagnostics_by_symbol = symbol_retriever.get_symbol_diagnostics(
|
||||
name_path=name_path,
|
||||
@@ -693,6 +703,8 @@ class SafeDeleteSymbol(Tool, ToolMarkerSymbolicEdit):
|
||||
:param name_path_pattern: name path of the symbol to delete
|
||||
:param relative_path: the relative path to the file containing the symbol to delete
|
||||
"""
|
||||
self.project.ls_sync_file_system_changes()
|
||||
|
||||
ls_symbol_retriever = self.create_language_server_symbol_retriever()
|
||||
symbol = ls_symbol_retriever.find_unique(name_path_pattern, substring_matching=False, within_relative_path=relative_path)
|
||||
symbol_rel_path = symbol.relative_path
|
||||
|
||||
@@ -12,7 +12,7 @@ from overrides import override
|
||||
from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer
|
||||
from solidlsp.ls_config import LanguageServerConfig
|
||||
from solidlsp.ls_utils import PlatformId, PlatformUtils
|
||||
from solidlsp.lsp_protocol_handler.lsp_types import Definition, DefinitionParams, LocationLink
|
||||
from solidlsp.lsp_protocol_handler.lsp_types import Definition, DefinitionParams, DidChangeConfigurationParams, LocationLink
|
||||
from solidlsp.settings import SolidLSPSettings
|
||||
|
||||
from ..lsp_protocol_handler import lsp_types
|
||||
@@ -35,6 +35,11 @@ class Intelephense(SolidLanguageServer):
|
||||
- maxMemory: sets intelephense.maxMemory
|
||||
- maxFileSize: sets intelephense.files.maxSize
|
||||
- ignore_vendor: whether or ignore directories named "vendor" (default: true)
|
||||
- file_filter: list of additional file extensions (with leading dot) to treat as PHP
|
||||
sources, e.g. [".module", ".inc"]. The extensions are added to the defaults (".php",
|
||||
".phtml") of Serena's PHP source-file matcher and are pushed to Intelephense via
|
||||
files.associations, such that the symbol tools and the language server treat the
|
||||
same files as PHP sources (see #1710).
|
||||
"""
|
||||
|
||||
@override
|
||||
@@ -106,6 +111,12 @@ class Intelephense(SolidLanguageServer):
|
||||
self._ignored_dirnames.add("vendor")
|
||||
log.info(f"Ignoring the following directories for PHP projects: {', '.join(sorted(self._ignored_dirnames))}")
|
||||
|
||||
# extend the source-file matcher with any additional extensions configured via `file_filter`,
|
||||
# such that Serena's symbol tools treat the added extensions as PHP sources as well (#1710)
|
||||
file_filter = self._custom_settings.get("file_filter")
|
||||
if file_filter:
|
||||
self.language.get_source_fn_matcher().add_extensions(*file_filter)
|
||||
|
||||
def _create_dependency_provider(self) -> LanguageServerDependencyProvider:
|
||||
return self.DependencyProvider(self._custom_settings, self._ls_resources_dir)
|
||||
|
||||
@@ -184,6 +195,17 @@ class Intelephense(SolidLanguageServer):
|
||||
|
||||
self.server.notify.initialized({})
|
||||
|
||||
# push the file associations derived from the source-file matcher (the default extensions
|
||||
# plus any configured via `file_filter`), such that Intelephense indexes the same set of
|
||||
# files that Serena's symbol tools treat as PHP sources (#1710). This cannot be passed in
|
||||
# the initialize request: Intelephense reads configuration exclusively from
|
||||
# workspace/didChangeConfiguration (its initializationOptions only cover storagePath,
|
||||
# globalStoragePath, clearCache and licenceKey).
|
||||
associations = [f"*{ext}" for ext in self.language.get_source_fn_matcher().file_extensions]
|
||||
intelephense_config: DidChangeConfigurationParams = {"settings": {"intelephense": {"files": {"associations": associations}}}}
|
||||
log.info(f"Sending workspace/didChangeConfiguration with file associations: {associations}")
|
||||
self.server.notify.workspace_did_change_configuration(intelephense_config)
|
||||
|
||||
# Intelephense server is typically ready immediately after initialization
|
||||
# TODO: This is probably incorrect; the server does send an initialized notification, which we could wait for!
|
||||
|
||||
|
||||
@@ -42,6 +42,13 @@ class FilenameMatcher:
|
||||
"""
|
||||
self._file_extensions = list(self._initial_file_extensions)
|
||||
|
||||
@property
|
||||
def file_extensions(self) -> list[str]:
|
||||
"""The file extensions currently registered with this matcher, including any added via
|
||||
:meth:`add_extensions`. Returned as a copy.
|
||||
"""
|
||||
return list(self._file_extensions)
|
||||
|
||||
def add_extensions(self, *file_extensions: str) -> None:
|
||||
"""
|
||||
Add further file extensions to this matcher (idempotent).
|
||||
@@ -448,7 +455,8 @@ class Language(str, Enum):
|
||||
case self.DART:
|
||||
return FilenameMatcher(".dart")
|
||||
case self.PHP | self.PHP_PHPACTOR | self.PHP_PHPANTOM:
|
||||
return FilenameMatcher(".php")
|
||||
# .phtml is a standard (yet outdated) extension for PHP sources
|
||||
return FilenameMatcher(".php", ".phtml")
|
||||
case self.R:
|
||||
return FilenameMatcher(".R", ".r", ".Rmd", ".Rnw")
|
||||
case self.PERL:
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* Drupal-style module file: ordinary PHP kept in a non-.php extension.
|
||||
*
|
||||
* Only visible to the symbol tools when ls_specific_settings["php"]["file_filter"] includes
|
||||
* ".module" (see #1710); inert for all tests running with default settings.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Implements hook_help().
|
||||
*/
|
||||
function drupal_module_help($route_name) {
|
||||
return 'Help for ' . $route_name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements hook_cron().
|
||||
*/
|
||||
function drupal_module_cron() {
|
||||
helperFunction();
|
||||
}
|
||||
|
||||
class DrupalModuleController {
|
||||
public function build(): string {
|
||||
return drupal_module_help('help.page.drupal_module');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
End-to-end test for language server file synchronisation as a result of externally-made file changes.
|
||||
|
||||
Exercises all three ``FileChangeType`` branches against a real pyright backend:
|
||||
Created (new caller file), Changed (append a second caller), Deleted (remove the caller file).
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
|
||||
import pytest
|
||||
|
||||
from solidlsp import SolidLanguageServer
|
||||
from solidlsp.ls_config import Language
|
||||
from test.conftest import get_repo_path, project_with_ls_context
|
||||
|
||||
pytestmark = pytest.mark.python
|
||||
|
||||
# A method that already has a call site in the fixture, so its baseline reference set is non-empty.
|
||||
_TARGET_FILE = os.path.join("test_repo", "services.py")
|
||||
_TARGET_SYMBOL = "create_user"
|
||||
|
||||
_CALLER_REL_PATH = os.path.join("test_repo", "external_caller.py")
|
||||
_CALLER_ONE = "external_caller_one"
|
||||
_CALLER_TWO = "external_caller_two"
|
||||
|
||||
|
||||
def _caller_source(*function_names: str) -> str:
|
||||
body = "\n\n".join(
|
||||
f"def {name}() -> User:\n return UserService().{_TARGET_SYMBOL}('x', 'y', 'z@example.com')" for name in function_names
|
||||
)
|
||||
return "from test_repo.models import User\nfrom test_repo.services import UserService\n\n\n" + body + "\n"
|
||||
|
||||
|
||||
def _referencing_symbol_names(ls: SolidLanguageServer) -> list[str]:
|
||||
"""Names of the symbols that reference ``_TARGET_SYMBOL``, per the warm language server."""
|
||||
document_symbols = ls.request_document_symbols(_TARGET_FILE).get_all_symbols_and_roots()
|
||||
target = next((s for s in document_symbols[0] if s.get("name") == _TARGET_SYMBOL), None)
|
||||
assert target is not None and "selectionRange" in target, f"{_TARGET_SYMBOL} not found in {_TARGET_FILE}"
|
||||
start = target["selectionRange"]["start"]
|
||||
references = ls.request_referencing_symbols(_TARGET_FILE, start["line"], start["character"])
|
||||
return [ref.symbol["name"] for ref in references]
|
||||
|
||||
|
||||
def test_external_changes_become_visible_after_sync(tmp_path):
|
||||
# Work on an isolated copy so we can freely create/edit/delete files under the project root.
|
||||
repo_root = tmp_path / "repo"
|
||||
shutil.copytree(get_repo_path(Language.PYTHON), repo_root)
|
||||
caller_abs = repo_root / _CALLER_REL_PATH
|
||||
|
||||
with project_with_ls_context(Language.PYTHON, str(repo_root)) as project:
|
||||
language_server = next(iter(project.language_server_manager.iter_language_servers()))
|
||||
|
||||
def sync_fs() -> int:
|
||||
return project.language_server_manager.sync_file_system_changes()
|
||||
|
||||
# Warm the reference index, then establish the freshness baseline (first poll never notifies).
|
||||
_referencing_symbol_names(language_server)
|
||||
assert sync_fs() == 0, "first poll must only establish the baseline"
|
||||
|
||||
# --- Created -------------------------------------------------------------------------------
|
||||
caller_abs.write_text(_caller_source(_CALLER_ONE), encoding="utf-8")
|
||||
|
||||
# Adversarial: without the poll, the warm server has never seen the new file -> stale.
|
||||
assert _CALLER_ONE not in _referencing_symbol_names(language_server), "expected the new caller to be invisible before poll"
|
||||
|
||||
assert sync_fs() == 1, "one Created event expected"
|
||||
assert _CALLER_ONE in _referencing_symbol_names(language_server), "new caller must be visible after poll"
|
||||
|
||||
# --- Changed -------------------------------------------------------------------------------
|
||||
caller_abs.write_text(_caller_source(_CALLER_ONE, _CALLER_TWO), encoding="utf-8")
|
||||
assert sync_fs() == 1, "one Changed event expected"
|
||||
names_after_change = _referencing_symbol_names(language_server)
|
||||
assert _CALLER_TWO in names_after_change, "appended caller must be visible after poll"
|
||||
assert _CALLER_ONE in names_after_change
|
||||
|
||||
# --- Deleted -------------------------------------------------------------------------------
|
||||
caller_abs.unlink()
|
||||
assert sync_fs() == 1, "one Deleted event expected"
|
||||
names_after_delete = _referencing_symbol_names(language_server)
|
||||
assert _CALLER_ONE not in names_after_delete, "deleted caller must disappear after poll"
|
||||
assert _CALLER_TWO not in names_after_delete
|
||||
|
||||
|
||||
def test_first_poll_is_baseline_only(tmp_path):
|
||||
"""A newly-loaded project must not report its entire pre-existing file set as changed."""
|
||||
repo_root = tmp_path / "repo"
|
||||
shutil.copytree(get_repo_path(Language.PYTHON), repo_root)
|
||||
with project_with_ls_context(Language.PYTHON, str(repo_root)) as project:
|
||||
assert project.ls_sync_file_system_changes() == 0
|
||||
# No external edits between the two calls -> still nothing to report.
|
||||
assert project.ls_sync_file_system_changes() == 0
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Configuration plumbing for ``Intelephense``'s ``file_filter``.
|
||||
|
||||
Serena treats only ``.php`` / ``.phtml`` files as PHP sources by default, while Drupal projects
|
||||
keep ordinary PHP in ``.module`` / ``.install`` / ``.inc`` / ``.theme`` / ``.profile`` /
|
||||
``.engine`` files, which therefore stay invisible to ``find_symbol`` and friends.
|
||||
``ls_specific_settings["php"]`` accepts a ``file_filter`` key with additional extensions,
|
||||
mirroring the Perl mechanism from #1449 / #1642 (see #1710).
|
||||
|
||||
The unit tests cover the default source-file matcher. The configuration plumbing itself lives
|
||||
inline in ``Intelephense.__init__`` (matcher sync) and ``_start_server`` (the
|
||||
``intelephense.files.associations`` push) and is exercised end-to-end by the integration test
|
||||
at the bottom, which starts a real Intelephense and is gated by the ``php`` marker like the
|
||||
rest of the PHP suite.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from solidlsp.ls_config import FilenameMatcher, Language
|
||||
from solidlsp.ls_utils import SymbolUtils
|
||||
from test.conftest import get_repo_path, start_ls_context
|
||||
|
||||
|
||||
class TestPhpSourceFnMatcherDefaults:
|
||||
def test_phtml_matched_by_default_for_all_php_language_servers(self) -> None:
|
||||
# .phtml is a standard (yet outdated) PHP extension, so all PHP language servers treat it
|
||||
# as a PHP source by default (#1710).
|
||||
for language in (Language.PHP, Language.PHP_PHPACTOR, Language.PHP_PHPANTOM):
|
||||
matcher = language.get_source_fn_matcher()
|
||||
assert matcher.is_relevant_filename("index.php"), f"{language}: .php not matched"
|
||||
assert matcher.is_relevant_filename("template.phtml"), f"{language}: .phtml not matched"
|
||||
|
||||
def test_module_not_matched_by_default(self) -> None:
|
||||
# guard for the integration test below: .module files only become visible via file_filter
|
||||
assert not Language.PHP.get_source_fn_matcher().is_relevant_filename("hooks.module")
|
||||
|
||||
def test_file_extensions_property_returns_copy(self) -> None:
|
||||
# _create_base_initialize_params derives the files.associations globs from this property;
|
||||
# mutating the returned list must not affect the matcher.
|
||||
matcher = FilenameMatcher(".php", ".phtml")
|
||||
extensions = matcher.file_extensions
|
||||
extensions.append(".module")
|
||||
|
||||
assert ".module" not in matcher.file_extensions
|
||||
assert not matcher.is_relevant_filename("hooks.module")
|
||||
|
||||
|
||||
@pytest.mark.php
|
||||
class TestFileFilterIntegration:
|
||||
"""End-to-end check that a custom ``file_filter`` makes a Drupal-style file visible.
|
||||
|
||||
Starts a real Intelephense, hence gated by the ``php`` marker. The ``drupal_module.module``
|
||||
fixture stays invisible to every test that runs with default settings.
|
||||
"""
|
||||
|
||||
def test_module_file_symbols_and_references_visible(self) -> None:
|
||||
with start_ls_context(
|
||||
Language.PHP,
|
||||
ls_specific_settings={Language.PHP: {"file_filter": [".module"]}},
|
||||
) as ls:
|
||||
# Layer 2 (files.associations) must be asserted FIRST: the reference in the
|
||||
# never-opened drupal_module.module can only come from the server's association-driven
|
||||
# background index. request_full_symbol_tree below didOpens every matched file in the
|
||||
# LS, after which this assertion could pass even without the associations.
|
||||
helper_php_path = str(get_repo_path(Language.PHP) / "helper.php")
|
||||
references = ls.request_references(helper_php_path, 2, len("function "))
|
||||
assert any(ref["uri"].endswith("drupal_module.module") for ref in references), (
|
||||
f"helperFunction call in drupal_module.module not found in references: {references}"
|
||||
)
|
||||
|
||||
# Layer 1 (Serena's source matcher): the .module file takes part in symbol traversal.
|
||||
symbols = ls.request_full_symbol_tree()
|
||||
assert SymbolUtils.symbol_tree_contains_name(symbols, "drupal_module_help"), (
|
||||
"drupal_module_help from drupal_module.module not found in the symbol tree"
|
||||
)
|
||||
assert SymbolUtils.symbol_tree_contains_name(symbols, "DrupalModuleController"), (
|
||||
"DrupalModuleController from drupal_module.module not found in the symbol tree"
|
||||
)
|
||||
Reference in new issue
Block a user