Add JetBrains facade (jb) covering all JetBrains tools

Introduce JetBrainsApi as the second facade implementation, exposing
all JetBrains IDE-backed operations to the REPL: find_symbol,
find_referencing_symbols, get_symbols_overview, get_type_hierarchy,
find_declaration, find_implementations, rename, move, safe_delete,
inline_symbol, run_inspections, list_inspections, debug_eval and
debug_eval_info (which provides the debug REPL usage information
otherwise obtained via the serena_info tool).

Result objects carry their rendering policy, following the LSP facade:
* JetBrainsSymbolCollection with renderers for symbol searches
  (grouped JSON, falling back to identifiers) and references (falling
  back to per-file counts and the total count)
* JetBrainsSymbolsOverview with the compact overview format and its
  shortening ladder
* JsonObject, a new general-purpose representable for plain JSON
  results with length limiting

The JetBrains tools now delegate to the API via a common JetBrainsTool
base, retaining only transport concerns (input sanitisation, the
wildcard-to-overview convenience of the find symbol tool), so that
both surfaces share one implementation. The agent adds the facade to
the REPL when the JetBrains backend is active.

serena_config imported JetBrainsPluginClient through a transitive
re-export from jetbrains_tools; it now imports it from its module.
This commit is contained in:
Dominik Jain authored and Dominik Jain committed 2026-09-15 10:27:45 +02:00
1 parent 2c9640c3ad
commit eb53a4cee4
7 files changed
+775 -318

No files matched your search

+3
View File
@@ -42,6 +42,7 @@ from serena.config.serena_config import (
ToolInclusionDefinition,
)
from serena.dashboard import SerenaDashboardAPI, SerenaDashboardTrayManager, SerenaDashboardViewer, open_url_in_browser
from serena.facades.api.jb import JetBrainsApi
from serena.facades.api.lsp import LspApi
from serena.facades.facade import Facade
from serena.facades.repl import SerenaRepl
@@ -1170,6 +1171,8 @@ class SerenaAgent:
facades = []
if self._language_backend.is_lsp():
facades.append(Facade.from_api(LspApi(self)))
elif self._language_backend.is_jetbrains():
facades.append(Facade.from_api(JetBrainsApi(self)))
self._repl = SerenaRepl(facades)
return self._repl
+1 -1
View File
@@ -1442,7 +1442,7 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes):
"""
Propagate settings from this configuration to individual components that are statically configured
"""
from serena.tools import JetBrainsPluginClient
from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient
JetBrainsPluginClient.set_server_address(self.jetbrains_plugin_server_address)
+611
View File
@@ -0,0 +1,611 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""
The implementation of JetBrains IDE-backed operations.
"""
from collections import Counter, defaultdict
from collections.abc import Iterator
from contextlib import contextmanager
from typing import TYPE_CHECKING, Any, Literal
import serena.jetbrains.jetbrains_types as jb
from serena.code_editor import JetBrainsCodeEditor
from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient
from serena.jetbrains.jetbrains_types import SymbolDTO, SymbolDTOUtil
from serena.symbol import JetBrainsSymbolDictGrouper
from serena.util.text_utils import find_text_coordinates
from ..facade import FacadeApi
from ..representable import JsonObject, JsonObjectRenderer, Renderer, RepresentableViaRenderer
if TYPE_CHECKING:
from serena.agent import SerenaAgent
class JetBrainsSymbolCollection(RepresentableViaRenderer):
"""
A collection of symbols retrieved via the JetBrains backend.
Each symbol is a dict with keys such as `name_path`, `relative_path` and `type`, and optionally
`children`, `body`, `quick_info`, `documentation` and (for references) `context`.
"""
def __init__(self, symbols: list[SymbolDTO], renderer: "JetBrainsSymbolCollectionRenderer"):
"""
:param symbols: the symbols
:param renderer: the renderer to use for representing the collection
"""
super().__init__(renderer)
self.symbols = symbols
def __len__(self) -> int:
return len(self.symbols)
def relative_paths_(self) -> list[str]:
return [s.get("relative_path", "unknown") for s in self.symbols]
def identifiers_(self) -> list[SymbolDTO]:
"""
:return: dicts containing only the identifying information (name_path, type, relative_path) of the symbols
"""
return [{"name_path": s["name_path"], "type": s["type"], "relative_path": s["relative_path"]} for s in self.symbols]
class JetBrainsSymbolCollectionRenderer(Renderer[JetBrainsSymbolCollection]):
"""
Renders a symbol collection as (optionally grouped) JSON, falling back to a listing of symbol identifiers
if the length limit is exceeded.
"""
def __init__(self, agent: "SerenaAgent", max_answer_chars: int, grouper: JetBrainsSymbolDictGrouper | None = None):
super().__init__(agent, max_answer_chars)
self._grouper = grouper
def _group(self, symbols: list[SymbolDTO]) -> Any:
return self._grouper.group(symbols) if self._grouper is not None else symbols
def render_identifiers(self, obj: JetBrainsSymbolCollection) -> str:
"""
:return: a shortened representation containing symbol types and identifiers (path + name_path) only, without children
"""
return f"Names with paths:\n{self._to_json(self._group(obj.identifiers_()))}"
def render(self, obj: JetBrainsSymbolCollection) -> str:
result = self._to_json(self._group(obj.symbols))
return self._limit_length(result, shortened_result_factories=[lambda: self.render_identifiers(obj)])
class JetBrainsReferencesRenderer(JetBrainsSymbolCollectionRenderer):
"""
Renders a collection of referencing symbols, falling back to per-file counts and finally the total count
if the length limit is exceeded.
"""
def render(self, obj: JetBrainsSymbolCollection) -> str:
ref_paths = obj.relative_paths_()
result = self._to_json(self._group(obj.symbols))
return self._limit_length(
result,
shortened_result_factories=[
lambda: f"Reference counts per file:\n{self._to_json(Counter(ref_paths))}",
lambda: f"Found {len(ref_paths)} references.",
],
)
class JetBrainsSymbolsOverview(RepresentableViaRenderer):
"""
The overview of the symbols defined in a file, i.e. the top-level symbols (each a dict with keys such as
`name_path` and `type`, optionally with `children`) and, if requested, the file's documentation.
"""
def __init__(self, symbols: list[SymbolDTO], documentation: str | None, renderer: "JetBrainsSymbolsOverviewRenderer"):
"""
:param symbols: the top-level symbols
:param documentation: the file's documentation, if requested and present
:param renderer: the renderer to use for representing the overview
"""
super().__init__(renderer)
self.symbols = symbols
self.documentation = documentation
class JetBrainsSymbolsOverviewRenderer(Renderer[JetBrainsSymbolsOverview]):
"""
Renders an overview in the compact grouped format, dropping (in order) the documentation, the children
and finally everything but symbol counts by type if the length limit is exceeded.
"""
def __init__(self, agent: "SerenaAgent", max_answer_chars: int, grouper: JetBrainsSymbolDictGrouper, depth: int):
super().__init__(agent, max_answer_chars)
self._grouper = grouper
self._depth = depth
def render(self, obj: JetBrainsSymbolsOverview) -> str:
grouped_symbols = self._grouper.group(obj.symbols)
shortened_result_factories = []
# create the full result
result: dict[str, Any] = {"symbols": grouped_symbols}
if obj.documentation:
result["docstring"] = obj.documentation
shortened_result_factories.append(lambda: self._to_json(grouped_symbols)) # shortened result without docstring
json_result = self._to_json(result)
# create shortened results
if self._depth > 0:
def create_short_result_depth_0() -> str:
depth_0_symbols = [d.copy() for d in obj.symbols]
for d in depth_0_symbols:
d.pop("children", None)
return "Depth 0 overview:\n" + self._to_json(self._grouper.group(depth_0_symbols))
shortened_result_factories.append(create_short_result_depth_0)
def create_short_result_type_counts() -> str:
type_names = [d.get("type", "unknown") for d in obj.symbols]
return f"Symbol counts by type:\n{self._to_json(Counter(type_names))}"
shortened_result_factories.append(create_short_result_type_counts)
return self._limit_length(json_result, shortened_result_factories=shortened_result_factories)
class JetBrainsApi(FacadeApi):
# groupers for the various symbol collections; top-level symbols are grouped by the first key list,
# children by the second
find_symbol_grouper_ = JetBrainsSymbolDictGrouper(
["relative_path", "type"], ["type"], collapse_singleton=True, map_name_path_to_name=True
)
references_grouper_ = JetBrainsSymbolDictGrouper(["relative_path", "type"], ["type"], collapse_singleton=True)
overview_grouper_ = JetBrainsSymbolDictGrouper(["type"], ["type"], collapse_singleton=True, map_name_path_to_name=True)
def __init__(self, agent: "SerenaAgent") -> None:
super().__init__(
agent,
name="jb",
description="JetBrains IDE-backed operations (symbol search, references, type hierarchies, refactorings, "
"inspections, debugging)",
)
@contextmanager
def _client(self) -> Iterator[JetBrainsPluginClient]:
with JetBrainsPluginClient.from_project(self._get_project()) as client:
yield client
def _json_object(self, data: Any, max_answer_chars: int = -1) -> JsonObject:
return JsonObject(data, JsonObjectRenderer(self._agent, max_answer_chars))
# read operations
def find_symbol(
self,
name_path_pattern: str,
depth: int = 0,
relative_path: str | None = None,
include_body: bool = False,
include_info: bool = False,
search_deps: bool = False,
max_matches: int = -1,
max_answer_chars: int = -1,
) -> JetBrainsSymbolCollection:
"""
Finds symbols and code entities (classes, methods, etc.) based on the given name path pattern.
The returned symbol information can be used for edits or further queries.
Specify `depth > 0` to retrieve children (e.g., methods of a class).
Important: through `search_deps=True` dependencies can be searched, which
should be preferred to web search or other less sophisticated approaches to analyzing dependencies.
You will always receive at least quick info for returned symbols (even if `include_info=False`).
A name path is a path in the symbol tree *within a source file*.
For example, the method `my_method` defined in class `MyClass` would have the name path `MyClass/my_method`.
If a symbol is overloaded (e.g., in Java), a 0-based index is appended (e.g. "MyClass/my_method[0]") to
uniquely identify it.
To search for a symbol, you provide a name path pattern that is used to match against name paths.
It can be
* a simple name (e.g. "method"), which will match any symbol with that name
* a relative path like "class/method", which will match any symbol with that name path suffix
* an absolute name path "/class/method" (absolute name path), which requires an exact match of the full name path within the source file.
Append an index `[i]` to match a specific overload only, e.g. "MyClass/my_method[1]".
In any path component, using `*` will match any sequence of characters (excluding /), e.g. "Class/*substring*" matches a member substring.
A pattern must not contain only wildcards (e.g. "*" or "/*"); use `get_symbols_overview` to list a file's symbols.
:param name_path_pattern: the name path matching pattern (see above)
:param depth: depth up to which descendants shall be retrieved (e.g. use 1 to also retrieve immediate children;
for the case where the symbol is a class, this will return its methods).
Ignored if `include_body=True`. Default 0.
:param relative_path: Optional. Restrict search to this file or directory. If not specified, searches entire codebase.
Note: for external dependencies, this must be an identifier starting with `<ext` that you have received
earlier (don't try to guess!).
:param include_body: If True, include the symbol's full source code.
:param include_info: whether to include additional info (hover-like, typically including docstring and signature),
about the symbol.
Default False; info is never included for child symbols or if include_body is True.
:param search_deps: If True, also search in project dependencies (e.g., libraries).
:param max_matches: Maximum number of permitted matches. If exceeded, an error containing a shortened result is raised,
which allows refining the search. -1 (default) means no limit. Set to 1 if you search for a single symbol.
:param max_answer_chars: max characters for the result (-1 for default). If exceeded, no content/a shortened result is returned.
:return: the symbols matching the pattern
"""
if name_path_pattern.replace("*", "").replace("/", "") == "":
raise ValueError("name_path_pattern must not be empty or contain only wildcards; consider using get_symbols_overview")
if include_body:
depth = 0 # ignore user-specified depth if body is requested
if relative_path == ".":
relative_path = None
if relative_path is not None and relative_path.startswith(jb.JB_EXTERNAL_FILE_PREFIX):
search_deps = True
# determine which additional information to request: if no additional information is requested,
# we still include the quick info (type signature)
include_documentation = include_info and not include_body
include_quick_info = not include_info and not include_body
with self._client() as client:
response = client.find_symbol(
name_path=name_path_pattern,
relative_path=relative_path,
depth=depth,
include_body=include_body,
include_documentation=include_documentation,
include_quick_info=include_quick_info,
search_deps=search_deps,
)
renderer = JetBrainsSymbolCollectionRenderer(self._agent, max_answer_chars, grouper=self.find_symbol_grouper_)
collection = JetBrainsSymbolCollection(response["symbols"], renderer)
n_matches = len(collection)
if 0 < max_matches < n_matches:
raise ValueError(f"Matched {n_matches}>{max_matches=} symbols.\n" + renderer.render_identifiers(collection))
return collection
def find_referencing_symbols(self, name_path: str, relative_path: str, max_answer_chars: int = -1) -> JetBrainsSymbolCollection:
"""
Finds all symbols that reference the given symbol — its callers / usages / dependents, i.e. the
symbols whose own definition (e.g. a method body) contains a reference to it. For each, returns its
name path, file, and the surrounding line of code.
:param name_path: name path of the symbol for which to find references
:param relative_path: the relative path to the file containing the symbol (must be a file, not a directory)
Note: for external dependencies, this must be an identifier starting with `<ext` that you have received
earlier (don't try to guess!).
:param max_answer_chars: max characters for the result (-1 for default). If exceeded, no content/a shortened result is returned.
:return: the referencing symbols
"""
with self._client() as client:
response = client.find_references(name_path=name_path, relative_path=relative_path, include_quick_info=False)
symbol_dicts = response["symbols"]
# replace reference line number (if present) by actual line/context
project = self._get_project()
for symbol_dict in symbol_dicts:
if "reference_line_no" in symbol_dict:
ref_line = symbol_dict["reference_line_no"]
if not SymbolDTOUtil.is_external_symbol(symbol_dict) and ref_line is not None and ref_line >= 0:
content_around_ref = project.retrieve_content_around_line(
relative_file_path=symbol_dict["relative_path"], line=ref_line, context_lines_before=1, context_lines_after=1
)
symbol_dict["context"] = content_around_ref.to_display_string()
del symbol_dict["reference_line_no"]
renderer = JetBrainsReferencesRenderer(self._agent, max_answer_chars, grouper=self.references_grouper_)
return JetBrainsSymbolCollection(symbol_dicts, renderer)
def get_symbols_overview(
self, relative_path: str, depth: int = -1, max_answer_chars: int = -1, include_file_documentation: bool = False
) -> JetBrainsSymbolsOverview:
"""
Gets an overview of the top-level symbols defined in the given file (classes, methods, fields) — its
STRUCTURE, without their bodies. This is the cheap, structure-first way to learn what a file
contains: it costs far less context than reading the whole file.
:param relative_path: the relative path to the file to get the overview of
:param depth: depth up to which descendants shall be retrieved.
Default (-1) results in a language specific choice: 1 for java and kotlin and 0 for other languages
:param max_answer_chars: max characters for the result (-1 for default). If exceeded, no content/a shortened result is returned.
:param include_file_documentation: whether to include the file's docstring. Default False.
:return: the overview
"""
if depth == -1:
depth = 1 if relative_path.endswith((".java", ".kt")) else 0
with self._client() as client:
response = client.get_symbols_overview(
relative_path=relative_path, depth=depth, include_file_documentation=include_file_documentation
)
renderer = JetBrainsSymbolsOverviewRenderer(self._agent, max_answer_chars, grouper=self.overview_grouper_, depth=depth)
return JetBrainsSymbolsOverview(response["symbols"], response.get("documentation"), renderer)
@staticmethod
def _transform_hierarchy_nodes(nodes: list[jb.TypeHierarchyNodeDTO] | None) -> dict[str, list]:
"""
Transforms a list of hierarchy nodes into a file-grouped compact format.
:return: a dict where keys are relative paths and values are lists of either a name path (for a leaf node)
or a dict mapping the name path to the (recursively transformed) children
"""
result: defaultdict[str, list] = defaultdict(list)
for node in nodes or []:
symbol = node["symbol"]
name_path = symbol["name_path"]
rel_path = symbol["relative_path"]
children = node.get("children", [])
if children:
result[rel_path].append({name_path: JetBrainsApi._transform_hierarchy_nodes(children)})
else:
result[rel_path].append(name_path)
return dict(result)
def get_type_hierarchy(
self,
name_path: str,
relative_path: str,
hierarchy_type: Literal["super", "sub", "both"] = "both",
depth: int | None = 1,
max_answer_chars: int = -1,
) -> JsonObject:
"""
Gets the type hierarchy of a symbol (supertypes, subtypes, or both).
:param name_path: name path of the symbol for which to get the type hierarchy.
:param relative_path: the relative path to the file containing the symbol.
:param hierarchy_type: which hierarchy to retrieve: "super" for parent classes/interfaces,
"sub" for subclasses/implementations, or "both" for both directions. Default is "both".
:param depth: depth limit for hierarchy traversal (None or 0 for unlimited). Default is 1.
:param max_answer_chars: max characters for the JSON result. If exceeded, no content is returned.
-1 means the default value from the config will be used.
:return: the file-grouped hierarchy, with keys "supertypes" and/or "subtypes" (and "levels_not_included"
if the depth limit truncated the hierarchy)
"""
result: dict[str, dict | list] = {}
levels_not_included = {}
with self._client() as client:
if hierarchy_type in ("super", "both"):
response = client.get_supertypes(name_path=name_path, relative_path=relative_path, depth=depth)
if "num_levels_not_included" in response:
levels_not_included["supertypes"] = response["num_levels_not_included"]
result["supertypes"] = self._transform_hierarchy_nodes(response.get("hierarchy"))
if hierarchy_type in ("sub", "both"):
response = client.get_subtypes(name_path=name_path, relative_path=relative_path, depth=depth)
if "num_levels_not_included" in response:
levels_not_included["subtypes"] = response["num_levels_not_included"]
result["subtypes"] = self._transform_hierarchy_nodes(response.get("hierarchy"))
if levels_not_included:
result["levels_not_included"] = levels_not_included
return self._json_object(result, max_answer_chars)
def find_declaration(self, relative_path: str, regex: str, include_body: bool = False) -> JetBrainsSymbolCollection:
r"""
Finds the declaration of a symbol.
:param relative_path: the relative path to the source file containing the symbol for which to find the declaration.
:param regex: a regular expression with one group, where the group matches the symbol for which to perform the lookup.
For example, to find the declaration of the `process` method in a call like `obj.process()`,
pass an expression like "obj\.(process)\(process_input_arg=37\)".
Prefer regexes with sufficiently large context around the group to render the match unambiguous.
Uses Python syntax with MULTILINE and DOTALL flags enabled.
:param include_body: whether to include the symbol's body in the result. Default False.
:return: the declaring symbol(s)
"""
content = self._get_project().read_file(relative_path)
coords = find_text_coordinates(content, regex, require_unique=True)
assert coords is not None
with self._client() as client:
response = client.find_declaration(
relative_path=relative_path, line=coords.line, col=coords.col, include_quick_info=False, include_body=include_body
)
return JetBrainsSymbolCollection(response["symbols"], JetBrainsSymbolCollectionRenderer(self._agent, -1))
def find_implementations(self, relative_path: str, name_path: str) -> JetBrainsSymbolCollection:
"""
Finds the implementations of a symbol.
:param relative_path: the relative path to the source file containing the symbol for which to find implementations.
:param name_path: name path of the symbol for which to find implementations
:return: the implementing symbols
"""
with self._client() as client:
response = client.find_implementations(relative_path=relative_path, name_path=name_path, include_quick_info=False)
return JetBrainsSymbolCollection(response["symbols"], JetBrainsSymbolCollectionRenderer(self._agent, -1))
# edit operations
def rename(
self,
relative_path: str,
new_name: str,
name_path: str | None = None,
rename_in_comments: bool = False,
rename_in_text_occurrences: bool = False,
) -> JsonObject:
"""
Renames a symbol, file or directory throughout the codebase.
Note: renaming in comments/text is on a best-effort basis by the IDE; if the symbol name is non-unique, further
verification is recommended.
:param relative_path: if `name_path` is passed, the relative path of the file containing the symbol.
Otherwise, the path to the directory or file to rename.
:param new_name: the new name
:param name_path: the name path of the symbol to rename or None if renaming a file or directory.
:param rename_in_comments: whether to also rename occurrences in comments. Default False.
:param rename_in_text_occurrences: whether to also rename occurrences in text. Default False.
:return: the result of the operation
"""
result = JetBrainsCodeEditor(self._get_project()).rename_symbol(
name_path=name_path,
relative_path=relative_path,
new_name=new_name,
rename_in_comments=rename_in_comments,
rename_in_text_occurrences=rename_in_text_occurrences,
)
return self._json_object(result)
def move(
self,
relative_path: str,
name_path: str | None = None,
target_relative_path: str | None = None,
target_parent_name_path: str | None = None,
) -> JsonObject:
"""
Moves a symbol, file or directory to a different location and automatically updates all references to affected symbols.
**Important**: this should always be preferred to naive moving (e.g. via file system operations or edits)
as it is much more reliable and efficient. It is always safe to use. For some symbols, moving may not be applicable,
and will result in no edits and a suitable error message.
The target location is the new parent of the symbol,
i.e. the moved entity is never renamed by the operation, only moved.
Valid moves:
- Symbol:
* (relative_path, name_path) -> new parent symbol (target_relative_path, target_parent_name_path)
* (relative_path, name_path) -> top level of target file or directory (target_relative_path)
Always consider the concrete language-specific semantics!
- target is a file: valid for languages like Python, where files are modules
- target is a directory: valid for languages like Java, where directories are packages and can contain classes
- File or directory:
* relative_path -> new parent directory (target_relative_path)
:param relative_path: the relative path to the file containing the symbol to move.
:param name_path: the name path of the symbol to move (empty for moving file or dir).
:param target_relative_path: the relative path of the target directory or file.
:param target_parent_name_path: the name path of the target parent symbol.
:return: the result of the operation
"""
with self._client() as client:
result = client.move(
name_path=name_path or None,
relative_path=relative_path,
target_parent_name_path=target_parent_name_path or None,
target_relative_path=target_relative_path or None,
)
return self._json_object(result)
def safe_delete(
self, relative_path: str, name_path: str | None = None, delete_even_if_used: bool = False, propagate: bool = False
) -> JsonObject:
"""
Safely deletes a symbol, file, or directory, checking for usages first and propagating deletion, if desired.
Propagation means it is possible to request deleting of usages and cleaning up of unused code.
Propagation is powerful for cleaning up code but should be used with care.
**Important**: this should always be preferred to naive deleting (e.g. via file system operations or edits).
When using it, you don't have to search for usages first, as the operation will do it for you.
:param relative_path: the relative path to the file containing the symbol to delete.
:param name_path: the name path of the symbol to delete.
A name path identifies a symbol within a source file, e.g. "MyClass/my_method".
Omit for deleting a file or directory.
:param delete_even_if_used: whether to force deletion even if the symbol still has usages.
Default is False (safe mode: will report usages instead of deleting).
:param propagate: whether to propagate the deletion to usages of the symbol and also
remove symbols that become unused after the deletion. Default is False.
:return: the result of the operation
"""
with self._client() as client:
result = client.safe_delete(
name_path=name_path or None, relative_path=relative_path, delete_even_if_used=delete_even_if_used, propagate=propagate
)
return self._json_object(result)
def inline_symbol(self, name_path: str, relative_path: str, keep_definition: bool = False) -> JsonObject:
"""
Inlines a symbol (usually a method/function, but also classes may be amenable to inlining,
which turns invocation into anonymous class creation),
replacing all call sites with the symbol's body.
**Important**: this should always be preferred to naive inlining (e.g. via searching for references and
editing them).
:param name_path: the name path of the symbol to inline.
:param relative_path: the relative path to the file containing the symbol to inline.
:param keep_definition: whether to keep the original method definition after inlining all call sites.
May be ignored in some cases (e.g. when inlining a class).
:return: the result of the operation
"""
with self._client() as client:
result = client.inline_symbol(name_path=name_path, relative_path=relative_path, keep_definition=keep_definition)
return self._json_object(result)
# inspections
def run_inspections(
self,
relative_path: str,
min_severity: str | None = None,
inspection_names: list[str] | None = None,
start_line: int | None = None,
end_line: int | None = None,
max_answer_chars: int = -1,
) -> JsonObject:
"""
Runs IDE inspections (code analysis) on the given file and returns the problems found.
This leverages the full power of JetBrains' static analysis engine, including language-specific
inspections, type checking, potential bugs, code style issues, and more.
:param relative_path: the relative path to the file to inspect.
:param min_severity: minimum severity level to include in results (e.g. "ERROR", "WARNING", "WEAK_WARNING", "INFO").
If not specified, all severities are returned.
:param inspection_names: optional list of specific inspection names to run (e.g. ["UnusedImport", "TypeMismatch"]).
If not specified, all applicable inspections are run.
:param start_line: optional 1-based start line to restrict the inspection range.
:param end_line: optional 1-based end line to restrict the inspection range.
:param max_answer_chars: max characters for the JSON result. If exceeded, no content is returned.
-1 means the default value from the config will be used.
:return: the inspection results including severity, message, and location.
"""
with self._client() as client:
result = client.run_inspections(
relative_path=relative_path,
min_severity=min_severity,
inspection_names=inspection_names,
start_line=start_line,
end_line=end_line,
)
return self._json_object(result, max_answer_chars)
def list_inspections(
self, language: str | None = None, group_path_contains: str | None = None, max_answer_chars: int = -1
) -> JsonObject:
"""
Lists the available IDE inspections. Use this to discover which inspections can be passed
to `run_inspections` via `inspection_names`.
:param language: optional language to filter by (e.g. "Java", "Python", "Kotlin").
:param group_path_contains: optional substring to match against the inspection group path
(e.g. "probable bugs", "code style").
:param max_answer_chars: max characters for the JSON result. If exceeded, no content is returned.
-1 means the default value from the config will be used.
:return: the list of available inspections including name, group path, and language.
"""
with self._client() as client:
result = client.list_inspections(language=language, group_path_contains=group_path_contains)
return self._json_object(result, max_answer_chars)
# debugging
def debug_eval_info(self) -> str:
"""
Provides usage information for the debug REPL (method `debug_eval`)
:return: the usage information
"""
return self._agent.prompt_factory.create_info_jet_brains_debug_repl()
def debug_eval(self, expression: str, repl_key: str = "default") -> str:
"""
Provides debugging functionality (run configs, breakpoints, stepping, inspection, and evaluation)
via a persistent debug REPL connected to the JetBrains IDE.
Call `debug_eval_info()` first for usage information.
:param expression: a Groovy/Java expression/statement to evaluate in the REPL.
If empty, closes the REPL with the given key.
:param repl_key: identifier for the REPL instance. State persists across calls with the same key.
:return: the string representation of the result
"""
with self._client() as client:
if expression:
response = client.debug_eval(repl_key=repl_key, expression=expression)
else:
response = client.debug_close(repl_key=repl_key)
return response.get("result", str(response))
+19
View File
@@ -74,3 +74,22 @@ class RepresentableViaRenderer(Representable):
def represent(self) -> str:
return self._renderer.render(self)
class JsonObject(RepresentableViaRenderer):
"""
A JSON-serializable result (dict, list, etc.) which is rendered as JSON, subject to length limitation.
"""
def __init__(self, data: Any, renderer: "JsonObjectRenderer"):
"""
:param data: the JSON-serializable data
:param renderer: the renderer to use for representing the data
"""
super().__init__(renderer)
self.data = data
class JsonObjectRenderer(Renderer[JsonObject]):
def render(self, obj: JsonObject) -> str:
return self._limit_length(self._to_json(obj.data))
+57 -317
View File
@@ -1,30 +1,28 @@
# SPDX-License-Identifier: GPL-3.0-or-later
import logging
from collections import Counter
from typing import Any, Literal
from typing import Literal
import serena.jetbrains.jetbrains_types as jb
from serena.code_editor import JetBrainsCodeEditor
from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient
from serena.jetbrains.jetbrains_types import SymbolDTO, SymbolDTOUtil
from serena.symbol import JetBrainsSymbolDictGrouper
from serena.facades.api.jb import JetBrainsApi
from serena.tools import Tool, ToolMarkerBeta, ToolMarkerOptional, ToolMarkerSymbolicEdit, ToolMarkerSymbolicRead
from serena.util.text_utils import find_text_coordinates
log = logging.getLogger(__name__)
class JetBrainsFindSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional):
class JetBrainsTool(Tool):
"""
Base class for tools which delegate to the JetBrains API
"""
def _api(self) -> JetBrainsApi:
return JetBrainsApi(self.agent)
class JetBrainsFindSymbolTool(JetBrainsTool, ToolMarkerSymbolicRead, ToolMarkerOptional):
"""
Performs a global (or local) search for symbols using the JetBrains backend
"""
# groups top-level symbols only; children are grouped separately by _group_children_by_type
symbol_dict_grouper = JetBrainsSymbolDictGrouper(
["relative_path", "type"], ["type"], collapse_singleton=True, map_name_path_to_name=True
)
def apply(
self,
name_path_pattern: str,
@@ -75,76 +73,40 @@ class JetBrainsFindSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional):
:param max_answer_chars: max characters for the result (-1 for default). If exceeded, no content/a shortened result is returned.
:return: symbols matching the name.
"""
# check input
# - pattern with only wildcards is invalid, but in some cases we delegate to the overview tool
if name_path_pattern.replace("*", "").replace("/", "") == "":
if relative_path:
if self.project.relative_path_exists(relative_path, require_file=True):
overview_tool = self.agent.get_tool(JetBrainsGetSymbolsOverviewTool)
overview_response = overview_tool.apply(relative_path, depth=depth)
return self._wrapped_tool_response(
overview_response, f"Wildcard-only pattern not admitted; used {overview_tool.get_name()} instead"
)
raise ValueError("name_path_pattern must not be empty or contain only wildcards; consider using the overview tool")
if include_body:
depth = 0 # ignore user-specified depth if body is requested
name_path_pattern = self._sanitize_input_param(name_path_pattern)
if relative_path:
relative_path = self._sanitize_input_param(relative_path)
if relative_path == ".":
relative_path = None
if relative_path is not None and relative_path.startswith(jb.JB_EXTERNAL_FILE_PREFIX):
search_deps = True
# for a wildcard-only pattern restricted to a file, delegate to the overview tool
if name_path_pattern.replace("*", "").replace("/", "") == "" and relative_path:
if self.project.relative_path_exists(relative_path, require_file=True):
overview_tool = self.agent.get_tool(JetBrainsGetSymbolsOverviewTool)
overview_response = overview_tool.apply(relative_path, depth=depth)
return self._wrapped_tool_response(
overview_response, f"Wildcard-only pattern not admitted; used {overview_tool.get_name()} instead"
)
with JetBrainsPluginClient.from_project(self.project) as client:
if include_body:
include_quick_info = False
include_documentation = False
else:
if include_info:
include_documentation = True
include_quick_info = False
else:
# If no additional information is requested, we still include the quick info (type signature)
include_documentation = False
include_quick_info = True
symbol_collection_response = client.find_symbol(
name_path=name_path_pattern,
relative_path=relative_path,
return (
self._api()
.find_symbol(
name_path_pattern,
depth=depth,
relative_path=relative_path,
include_body=include_body,
include_documentation=include_documentation,
include_quick_info=include_quick_info,
include_info=include_info,
search_deps=search_deps,
max_matches=max_matches,
max_answer_chars=max_answer_chars,
)
symbols = symbol_collection_response["symbols"]
def create_shortened_result() -> str:
"""Shortened results containing symbol types and identifiers (path + name_path) only, without children"""
dicts: list[SymbolDTO] = [
{"name_path": s["name_path"], "type": s["type"], "relative_path": s["relative_path"]} for s in symbols
]
grouped = self.symbol_dict_grouper.group(dicts)
return f"Names with paths:\n{self._to_json(grouped)}"
n_matches = len(symbols)
if 0 < max_matches < n_matches:
return f"Matched {n_matches}>{max_matches=} symbols.\n" + create_shortened_result()
grouped_symbols = self.symbol_dict_grouper.group(symbols)
result = self._to_json(grouped_symbols)
return self._limit_length(result, max_answer_chars, shortened_result_factories=[create_shortened_result])
.represent()
)
@classmethod
def get_param_aliases(cls) -> dict[str, str]:
return {"name_path": "name_path_pattern"}
class JetBrainsMoveTool(Tool, ToolMarkerSymbolicEdit, ToolMarkerOptional, ToolMarkerBeta):
class JetBrainsMoveTool(JetBrainsTool, ToolMarkerSymbolicEdit, ToolMarkerOptional, ToolMarkerBeta):
"""
Moves a symbol, file or directory to a new location using the JetBrains backend, updating all references
"""
@@ -180,21 +142,11 @@ class JetBrainsMoveTool(Tool, ToolMarkerSymbolicEdit, ToolMarkerOptional, ToolMa
:param target_relative_path: the relative path of the target directory or file.
:param target_parent_name_path: the name path of the target parent symbol.
"""
name_path = name_path or None
target_relative_path = target_relative_path or None
target_parent_name_path = target_parent_name_path or None
relative_path = self._sanitize_input_param(relative_path)
with JetBrainsPluginClient.from_project(self.project) as client:
response_dict = client.move(
name_path=name_path,
relative_path=relative_path,
target_parent_name_path=target_parent_name_path,
target_relative_path=target_relative_path,
)
return self._to_json(response_dict)
return self._api().move(relative_path, name_path, target_relative_path, target_parent_name_path).represent()
class JetBrainsSafeDeleteTool(Tool, ToolMarkerSymbolicEdit, ToolMarkerOptional, ToolMarkerBeta):
class JetBrainsSafeDeleteTool(JetBrainsTool, ToolMarkerSymbolicEdit, ToolMarkerOptional, ToolMarkerBeta):
"""
Safely deletes a symbol using the JetBrains backend, checking for remaining usages first
"""
@@ -223,18 +175,10 @@ class JetBrainsSafeDeleteTool(Tool, ToolMarkerSymbolicEdit, ToolMarkerOptional,
remove symbols that become unused after the deletion. Default is False.
"""
relative_path = self._sanitize_input_param(relative_path)
name_path = name_path or None
with JetBrainsPluginClient.from_project(self.project) as client:
response_dict = client.safe_delete(
name_path=name_path,
relative_path=relative_path,
delete_even_if_used=delete_even_if_used,
propagate=propagate,
)
return self._to_json(response_dict)
return self._api().safe_delete(relative_path, name_path, delete_even_if_used, propagate).represent()
class JetBrainsInlineSymbol(Tool, ToolMarkerSymbolicEdit, ToolMarkerOptional, ToolMarkerBeta):
class JetBrainsInlineSymbol(JetBrainsTool, ToolMarkerSymbolicEdit, ToolMarkerOptional, ToolMarkerBeta):
"""
Inlines a symbol using the JetBrains backend, replacing all call sites with the symbol's body
"""
@@ -258,22 +202,14 @@ class JetBrainsInlineSymbol(Tool, ToolMarkerSymbolicEdit, ToolMarkerOptional, To
May be ignored in some cases (e.g. when inlining a class).
"""
relative_path = self._sanitize_input_param(relative_path)
with JetBrainsPluginClient.from_project(self.project) as client:
response_dict = client.inline_symbol(
name_path=name_path,
relative_path=relative_path,
keep_definition=keep_definition,
)
return self._to_json(response_dict)
return self._api().inline_symbol(name_path, relative_path, keep_definition).represent()
class JetBrainsFindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional):
class JetBrainsFindReferencingSymbolsTool(JetBrainsTool, ToolMarkerSymbolicRead, ToolMarkerOptional):
"""
Finds symbols that reference the given symbol using the JetBrains backend
"""
symbol_dict_grouper = JetBrainsSymbolDictGrouper(["relative_path", "type"], ["type"], collapse_singleton=True)
def apply(
self,
name_path: str,
@@ -292,53 +228,14 @@ class JetBrainsFindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead, ToolMark
:param max_answer_chars: max characters for the result (-1 for default). If exceeded, no content/a shortened result is returned.
"""
relative_path = self._sanitize_input_param(relative_path)
with JetBrainsPluginClient.from_project(self.project) as client:
response_dict = client.find_references(
name_path=name_path,
relative_path=relative_path,
include_quick_info=False,
)
symbol_dicts = response_dict["symbols"]
# replace reference line number (if present) by actual line/context
for symbol_dict in symbol_dicts:
if "reference_line_no" in symbol_dict:
ref_line = symbol_dict["reference_line_no"]
ref_relative_path = symbol_dict["relative_path"]
if not SymbolDTOUtil.is_external_symbol(symbol_dict) and ref_line is not None and ref_line >= 0:
content_around_ref = self.project.retrieve_content_around_line(
relative_file_path=ref_relative_path, line=ref_line, context_lines_before=1, context_lines_after=1
)
symbol_dict["context"] = content_around_ref.to_display_string()
del symbol_dict["reference_line_no"]
# capture file paths before grouping
ref_paths = [s.get("relative_path", "unknown") for s in symbol_dicts]
result = self.symbol_dict_grouper.group(symbol_dicts)
def create_shortened_result_counts_per_file() -> str:
return f"Reference counts per file:\n{self._to_json(Counter(ref_paths))}"
def create_shortened_result_num_results() -> str:
return f"Found {len(ref_paths)} references."
result_json = self._to_json(result)
return self._limit_length(
result_json,
max_answer_chars,
shortened_result_factories=[create_shortened_result_counts_per_file, create_shortened_result_num_results],
)
return self._api().find_referencing_symbols(name_path, relative_path, max_answer_chars).represent()
class JetBrainsGetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional):
class JetBrainsGetSymbolsOverviewTool(JetBrainsTool, ToolMarkerSymbolicRead, ToolMarkerOptional):
"""
Retrieves an overview of the top-level symbols within a specified file using the JetBrains backend
"""
USE_COMPACT_FORMAT = True
symbol_dict_grouper = JetBrainsSymbolDictGrouper(["type"], ["type"], collapse_singleton=True, map_name_path_to_name=True)
def apply(
self,
relative_path: str,
@@ -357,95 +254,15 @@ class JetBrainsGetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOp
:param max_answer_chars: max characters for the result (-1 for default). If exceeded, no content/a shortened result is returned.
:param include_file_documentation: whether to include the file's docstring. Default False.
"""
if depth == -1:
if relative_path.endswith((".java", ".kt")):
depth = 1
else:
depth = 0
relative_path = self._sanitize_input_param(relative_path)
with JetBrainsPluginClient.from_project(self.project) as client:
symbol_overview = client.get_symbols_overview(
relative_path=relative_path, depth=depth, include_file_documentation=include_file_documentation
)
if self.USE_COMPACT_FORMAT:
symbols = symbol_overview["symbols"]
grouped_symbols = self.symbol_dict_grouper.group(symbols)
shortened_result_factories = []
# create full result
result: dict[str, Any] = {"symbols": grouped_symbols}
documentation = symbol_overview.pop("documentation", None)
if documentation:
result["docstring"] = documentation
shortened_result_factories.append(lambda: self._to_json(grouped_symbols)) # shortened result without docstring
json_result = self._to_json(result)
if depth > 0:
def create_short_result_depth_0() -> str:
depth_0_symbols = [d.copy() for d in symbols]
for d in depth_0_symbols:
d.pop("children", None)
compact_depth_0_result = self.symbol_dict_grouper.group(depth_0_symbols)
return "Depth 0 overview:\n" + self._to_json(compact_depth_0_result)
shortened_result_factories.append(create_short_result_depth_0)
def create_short_result_type_counts() -> str:
type_names = [d.get("type", "unknown") for d in symbols]
return f"Symbol counts by type:\n{self._to_json(Counter(type_names))}"
shortened_result_factories.append(create_short_result_type_counts)
else:
# this path is currently abandoned, consider introducing shortened results if ever needed
shortened_result_factories = None
json_result = self._to_json(symbol_overview)
return self._limit_length(json_result, max_answer_chars, shortened_result_factories=shortened_result_factories)
return self._api().get_symbols_overview(relative_path, depth, max_answer_chars, include_file_documentation).represent()
class JetBrainsTypeHierarchyTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional):
class JetBrainsTypeHierarchyTool(JetBrainsTool, ToolMarkerSymbolicRead, ToolMarkerOptional):
"""
Retrieves the type hierarchy (supertypes and/or subtypes) of a symbol using the JetBrains backend
"""
@staticmethod
def _transform_hierarchy_nodes(nodes: list[jb.TypeHierarchyNodeDTO] | None) -> dict[str, list]:
"""
Transform a list of TypeHierarchyNode into a file-grouped compact format.
Returns a dict where keys are relative_paths and values are lists of either:
- "SymbolNamePath" (leaf node)
- {"SymbolNamePath": {nested_file_grouped_children}} (node with children)
"""
if not nodes:
return {}
result: dict[str, list] = {}
for node in nodes:
symbol = node["symbol"]
name_path = symbol["name_path"]
rel_path = symbol["relative_path"]
children = node.get("children", [])
if rel_path not in result:
result[rel_path] = []
if children:
# Node with children - recurse
nested = JetBrainsTypeHierarchyTool._transform_hierarchy_nodes(children)
result[rel_path].append({name_path: nested})
else:
# Leaf node
result[rel_path].append(name_path)
return result
def apply(
self,
name_path: str,
@@ -460,51 +277,17 @@ class JetBrainsTypeHierarchyTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptiona
:param name_path: name path of the symbol for which to get the type hierarchy.
:param relative_path: the relative path to the file containing the symbol.
:param hierarchy_type: which hierarchy to retrieve: "super" for parent classes/interfaces,
"sub" for subclasses/implementations, or "both" for both directions. Default is "sub".
"sub" for subclasses/implementations, or "both" for both directions. Default is "both".
:param depth: depth limit for hierarchy traversal (None or 0 for unlimited). Default is 1.
:param max_answer_chars: max characters for the JSON result. If exceeded, no content is returned.
-1 means the default value from the config will be used.
:return: Compact JSON with file-grouped hierarchy. Error string if not applicable.
"""
relative_path = self._sanitize_input_param(relative_path)
with JetBrainsPluginClient.from_project(self.project) as client:
subtypes = None
supertypes = None
levels_not_included = {}
if hierarchy_type in ("super", "both"):
supertypes_response = client.get_supertypes(
name_path=name_path,
relative_path=relative_path,
depth=depth,
)
if "num_levels_not_included" in supertypes_response:
levels_not_included["supertypes"] = supertypes_response["num_levels_not_included"]
supertypes = self._transform_hierarchy_nodes(supertypes_response.get("hierarchy"))
if hierarchy_type in ("sub", "both"):
subtypes_response = client.get_subtypes(
name_path=name_path,
relative_path=relative_path,
depth=depth,
)
if "num_levels_not_included" in subtypes_response:
levels_not_included["subtypes"] = subtypes_response["num_levels_not_included"]
subtypes = self._transform_hierarchy_nodes(subtypes_response.get("hierarchy"))
result_dict: dict[str, dict | list] = {}
if supertypes is not None:
result_dict["supertypes"] = supertypes
if subtypes is not None:
result_dict["subtypes"] = subtypes
if levels_not_included:
result_dict["levels_not_included"] = levels_not_included
result = self._to_json(result_dict)
return self._limit_length(result, max_answer_chars)
return self._api().get_type_hierarchy(name_path, relative_path, hierarchy_type, depth, max_answer_chars).represent()
class JetBrainsFindDeclarationTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional):
class JetBrainsFindDeclarationTool(JetBrainsTool, ToolMarkerSymbolicRead, ToolMarkerOptional):
"""
Finds the declaration of a symbol using the JetBrains backend
"""
@@ -523,20 +306,10 @@ class JetBrainsFindDeclarationTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptio
"""
relative_path = self._sanitize_input_param(relative_path)
regex = self._sanitize_input_param(regex)
editor = self.create_code_editor()
content = editor.read_file(relative_path)
coords = find_text_coordinates(content, regex, require_unique=True)
assert coords is not None
with JetBrainsPluginClient.from_project(self.project) as client:
symbol_collection = client.find_declaration(
relative_path=relative_path, line=coords.line, col=coords.col, include_quick_info=False, include_body=include_body
)
result = self._to_json(symbol_collection)
return result
return self._api().find_declaration(relative_path, regex, include_body).represent()
class JetBrainsFindImplementationsTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional):
class JetBrainsFindImplementationsTool(JetBrainsTool, ToolMarkerSymbolicRead, ToolMarkerOptional):
"""
Finds the implementations of a symbol using the JetBrains backend
"""
@@ -548,17 +321,10 @@ class JetBrainsFindImplementationsTool(Tool, ToolMarkerSymbolicRead, ToolMarkerO
:param relative_path: the relative path to the source file containing the symbol for which to find implementations.
:param name_path: name path of the symbol for which to find implementations
"""
with JetBrainsPluginClient.from_project(self.project) as client:
symbol_collection = client.find_implementations(
relative_path=relative_path,
name_path=name_path,
include_quick_info=False,
)
result = self._to_json(symbol_collection)
return result
return self._api().find_implementations(relative_path, name_path).represent()
class JetBrainsRenameTool(Tool, ToolMarkerSymbolicEdit, ToolMarkerOptional):
class JetBrainsRenameTool(JetBrainsTool, ToolMarkerSymbolicEdit, ToolMarkerOptional):
"""
Renames a symbol, file or directory throughout the codebase using the JetBrains backend.
"""
@@ -584,18 +350,10 @@ class JetBrainsRenameTool(Tool, ToolMarkerSymbolicEdit, ToolMarkerOptional):
:param rename_in_text_occurrences: whether to also rename occurrences in text. Default True.
:return: a status message
"""
code_editor = JetBrainsCodeEditor(self.project)
result = code_editor.rename_symbol(
name_path=name_path,
relative_path=relative_path,
new_name=new_name,
rename_in_comments=rename_in_comments,
rename_in_text_occurrences=rename_in_text_occurrences,
)
return self._to_json(result)
return self._api().rename(relative_path, new_name, name_path, rename_in_comments, rename_in_text_occurrences).represent()
class JetBrainsDebugTool(Tool, ToolMarkerOptional, ToolMarkerBeta):
class JetBrainsDebugTool(JetBrainsTool, ToolMarkerOptional, ToolMarkerBeta):
"""
Provides debugging functionality (run configs, breakpoints, stepping, inspection, and evaluation)
via a persistent debug REPL connected to the JetBrains IDE.
@@ -617,15 +375,10 @@ class JetBrainsDebugTool(Tool, ToolMarkerOptional, ToolMarkerBeta):
:param repl_key: identifier for the REPL instance. State persists across calls with the same key.
:return: string representation of the result
"""
with JetBrainsPluginClient.from_project(self.project) as client:
if expression:
response = client.debug_eval(repl_key=repl_key, expression=expression)
else:
response = client.debug_close(repl_key=repl_key)
return response.get("result", str(response))
return self._api().debug_eval(expression, repl_key)
class JetBrainsRunInspectionsTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional):
class JetBrainsRunInspectionsTool(JetBrainsTool, ToolMarkerSymbolicRead, ToolMarkerOptional):
"""
Runs JetBrains IDE inspections on a file and returns the results.
"""
@@ -655,19 +408,12 @@ class JetBrainsRunInspectionsTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOption
-1 means the default value from the config will be used.
:return: JSON string with inspection results including severity, message, and location.
"""
with JetBrainsPluginClient.from_project(self.project) as client:
response_dict = client.run_inspections(
relative_path=relative_path,
min_severity=min_severity,
inspection_names=inspection_names,
start_line=start_line,
end_line=end_line,
)
result = self._to_json(response_dict)
return self._limit_length(result, max_answer_chars)
return (
self._api().run_inspections(relative_path, min_severity, inspection_names, start_line, end_line, max_answer_chars).represent()
)
class JetBrainsListInspectionsTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional):
class JetBrainsListInspectionsTool(JetBrainsTool, ToolMarkerSymbolicRead, ToolMarkerOptional):
"""
Lists available JetBrains IDE inspections, optionally filtered by language or group.
"""
@@ -689,10 +435,4 @@ class JetBrainsListInspectionsTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptio
-1 means the default value from the config will be used.
:return: JSON string with the list of available inspections including name, group path, and language.
"""
with JetBrainsPluginClient.from_project(self.project) as client:
response_dict = client.list_inspections(
language=language,
group_path_contains=group_path_contains,
)
result = self._to_json(response_dict)
return self._limit_length(result, max_answer_chars)
return self._api().list_inspections(language, group_path_contains, max_answer_chars).represent()
+79
View File
@@ -0,0 +1,79 @@
"""
Tests for the JetBrains facade API, using a mocked plugin client (no IDE required).
"""
from unittest.mock import MagicMock, patch
import pytest
from serena.facades.api.jb import JetBrainsApi
from serena.facades.facade import Facade
@pytest.fixture
def agent() -> MagicMock:
agent = MagicMock()
agent.serena_config.default_max_tool_answer_chars = 10000
return agent
@pytest.fixture
def client() -> MagicMock:
client = MagicMock()
with patch("serena.facades.api.jb.JetBrainsPluginClient.from_project") as from_project:
from_project.return_value.__enter__.return_value = client
yield client
def test_facade_exposes_all_jetbrains_operations(agent: MagicMock) -> None:
facade = Facade.from_api(JetBrainsApi(agent))
assert facade.name == "jb"
assert set(facade.enabled_method_names) == {
"find_symbol",
"find_referencing_symbols",
"get_symbols_overview",
"get_type_hierarchy",
"find_declaration",
"find_implementations",
"rename",
"move",
"safe_delete",
"inline_symbol",
"run_inspections",
"list_inspections",
"debug_eval",
"debug_eval_info",
}
def test_find_symbol_renders_grouped_and_exposes_symbols(agent: MagicMock, client: MagicMock) -> None:
symbols = [
{"name_path": "Foo", "type": "class", "relative_path": "a.py", "quick_info": "class Foo"},
{"name_path": "Bar/foo", "type": "method", "relative_path": "b.py", "quick_info": "def foo()"},
]
client.find_symbol.return_value = {"symbols": symbols}
result = JetBrainsApi(agent).find_symbol("foo")
# the underlying symbols are accessible from code
assert [s["name_path"] for s in result.symbols] == ["Foo", "Bar/foo"]
# and the rendering contains them, grouped by file
rendered = result.represent()
assert '"a.py"' in rendered and '"b.py"' in rendered
assert "class Foo" in rendered
def test_find_symbol_rejects_too_many_matches_with_identifiers(agent: MagicMock, client: MagicMock) -> None:
client.find_symbol.return_value = {
"symbols": [{"name_path": f"foo{i}", "type": "function", "relative_path": "a.py", "body": "..."} for i in range(3)]
}
with pytest.raises(ValueError, match="Matched 3>max_matches=1") as exc_info:
JetBrainsApi(agent).find_symbol("foo*", max_matches=1)
assert "foo2" in str(exc_info.value)
assert '"body"' not in str(exc_info.value) # only identifiers, no content
def test_find_symbol_rejects_wildcard_only_pattern(agent: MagicMock, client: MagicMock) -> None:
with pytest.raises(ValueError, match="get_symbols_overview"):
JetBrainsApi(agent).find_symbol("*")
client.find_symbol.assert_not_called()
+5
View File
@@ -8,6 +8,7 @@ from serena import __version__
from serena.agent import Tool, ToolRegistry
from serena.config.context_mode import SerenaAgentContext
from serena.config.serena_config import SerenaConfig
from serena.facades.repl import SerenaRepl
from serena.mcp import SerenaMCPFactory
make_tool = SerenaMCPFactory.make_mcp_tool
@@ -23,6 +24,10 @@ class MockAgent:
def get_context() -> SerenaAgentContext:
return SerenaAgentContext.load_default()
@staticmethod
def get_repl() -> SerenaRepl:
return SerenaRepl([])
class BaseMockTool(Tool):
"""A mock Tool class for testing."""