Merge branch 'main' of github.com:oraios/serena

This commit is contained in:
Dominik Jain authored and Dominik Jain committed 2026-07-21 01:26:26 +02:00
commit 9383bf788c
10 files changed
+388 -9

No files matched your search

+10
View File
@@ -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`
+13
View File
@@ -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
View File
@@ -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)
+9 -1
View File
@@ -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)
+13 -1
View File
@@ -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
+23 -1
View File
@@ -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!
+9 -1
View File
@@ -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');
}
}
+92
View File
@@ -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
+77
View File
@@ -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"
)