Refactor name path-based symbol retrieval methods

* Improve parameter and method names
  * Consolidate documentation
  * Add unique symbol retrieval method to facilitate reuse

Fix FindReferencingSymbolsTool glossing over non-unique symbol specification
(must raise an error if the name path is not unique)
This commit is contained in:
Dominik Jain authored and Dominik Jain committed 2025-12-08 15:10:16 +01:00
1 parent 2e19f7e098
commit bb79bf98d2
3 files changed
+62 -60

No files matched your search

+1 -9
View File
@@ -267,15 +267,7 @@ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]):
return lang_server.language_server.retrieve_full_file_content(relative_path)
def _find_unique_symbol(self, name_path: str, relative_file_path: str) -> LanguageServerSymbol:
symbol_candidates = self._symbol_retriever.find_by_name(name_path, within_relative_path=relative_file_path)
if len(symbol_candidates) == 0:
raise ValueError(f"No symbol with name {name_path} found in file {relative_file_path}")
if len(symbol_candidates) > 1:
raise ValueError(
f"Found multiple {len(symbol_candidates)} symbols with name {name_path} in file {relative_file_path}. "
"Their locations are: \n " + json.dumps([s.location.to_dict() for s in symbol_candidates], indent=2)
)
return symbol_candidates[0]
return self._symbol_retriever.find_unique(name_path, within_relative_path=relative_file_path)
def _apply_workspace_edit(self, workspace_edit: ls_types.WorkspaceEdit) -> list[str]:
"""
+60 -50
View File
@@ -115,16 +115,31 @@ class Symbol(ToStringMixin, ABC):
class NamePathMatcher(ToStringMixin):
def __init__(self, name_path_expr: str, substring_matching: bool) -> None:
"""
Matches name paths of symbols against search patterns.
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.
A matching pattern 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]".
"""
def __init__(self, name_path_pattern: str, substring_matching: bool) -> None:
"""
:param name_path_expr: the name path expression to match against
:param name_path_pattern: the name path expression to match against
:param substring_matching: whether to use substring matching for the last segment
"""
assert name_path_expr, "name_path must not be empty"
self._expr = name_path_expr
assert name_path_pattern, "name_path must not be empty"
self._expr = name_path_pattern
self._substring_matching = substring_matching
self._is_absolute_pattern = name_path_expr.startswith(NAME_PATH_SEP)
self._pattern_parts = name_path_expr.lstrip(NAME_PATH_SEP).rstrip(NAME_PATH_SEP).split(NAME_PATH_SEP)
self._is_absolute_pattern = name_path_pattern.startswith(NAME_PATH_SEP)
self._pattern_parts = name_path_pattern.lstrip(NAME_PATH_SEP).rstrip(NAME_PATH_SEP).split(NAME_PATH_SEP)
# extract overload index "[idx]" if present at end of last part
self._overload_idx: int | None = None
@@ -323,34 +338,15 @@ class LanguageServerSymbol(Symbol, ToStringMixin):
def find(
self,
name_path: str,
name_path_pattern: str,
substring_matching: bool = False,
include_kinds: Sequence[SymbolKind] | None = None,
exclude_kinds: Sequence[SymbolKind] | None = None,
) -> list[Self]:
"""
Find all symbols within the symbol's subtree that match the given `name_path`.
The matching behavior is determined by the structure of `name_path`, which can
either be a simple name (e.g. "method") or a name path like "class/method" (relative name path)
or "/class/method" (absolute name path).
Find all symbols within the symbol's subtree that match the given name path pattern.
Key aspects of the name path matching behavior:
- Trailing slashes in `name_path` play no role and are ignored.
- The name of the retrieved symbols will match (either exactly or as a substring)
the last segment of `name_path`, while other segments will restrict the search to symbols that
have a desired sequence of ancestors.
- If there is no starting or intermediate slash in `name_path`, there is no
restriction on the ancestor symbols. For example, passing `method` will match
against symbols with name paths like `method`, `class/method`, `class/nested_class/method`, etc.
- If `name_path` contains a `/` but doesn't start with a `/`, the matching is restricted to symbols
with the same ancestors as the last segment of `name_path`. For example, passing `class/method` will match against
`class/method` as well as `nested_class/class/method` but not `method`.
- If `name_path` starts with a `/`, it will be treated as an absolute name path pattern, meaning
that the first segment of it must match the first segment of the symbol's name path.
For example, passing `/class` will match only against top-level symbols like `class` but not against `nested_class/class`.
Passing `/class/method` will match against `class/method` but not `nested_class/class/method` or `method`.
:param name_path: the name path expression to match against
:param name_path_pattern: the name path pattern to match against (see class :class:`NamePathMatcher` for details)
:param substring_matching: whether to use substring matching (as opposed to exact matching)
of the last segment of `name_path` against the symbol name.
:param include_kinds: an optional sequence of ints representing the LSP symbol kind.
@@ -358,7 +354,7 @@ class LanguageServerSymbol(Symbol, ToStringMixin):
:param exclude_kinds: If provided, symbols of the given kinds will be excluded from the result.
"""
result = []
name_path_matcher = NamePathMatcher(name_path, substring_matching)
name_path_matcher = NamePathMatcher(name_path_pattern, substring_matching)
def should_include(s: "LanguageServerSymbol") -> bool:
if include_kinds is not None and s.symbol_kind not in include_kinds:
@@ -488,18 +484,17 @@ class LanguageServerSymbolRetriever:
def get_language_server(self, relative_path: str) -> SolidLanguageServer:
return self._ls_manager.get_language_server(relative_path)
def find_by_name(
def find(
self,
name_path: str,
name_path_pattern: str,
include_kinds: Sequence[SymbolKind] | None = None,
exclude_kinds: Sequence[SymbolKind] | None = None,
substring_matching: bool = False,
within_relative_path: str | None = None,
) -> list[LanguageServerSymbol]:
"""
Find all symbols that match the given name. See docstring of `Symbol.find` for more details.
The only parameter not mentioned there is `within_relative_path`, which can be used to restrict the search
to symbols within a specific file or directory.
Finds all symbols that match the given name path pattern (see class :class:`NamePathMatcher` for details),
optionally limited to a specific file and filtered by kind.
"""
symbols: list[LanguageServerSymbol] = []
for lang_server in self._ls_manager.iter_language_servers():
@@ -507,11 +502,37 @@ class LanguageServerSymbolRetriever:
for root in symbol_roots:
symbols.extend(
LanguageServerSymbol(root).find(
name_path, include_kinds=include_kinds, exclude_kinds=exclude_kinds, substring_matching=substring_matching
name_path_pattern, include_kinds=include_kinds, exclude_kinds=exclude_kinds, substring_matching=substring_matching
)
)
return symbols
def find_unique(
self,
name_path_pattern: str,
include_kinds: Sequence[SymbolKind] | None = None,
exclude_kinds: Sequence[SymbolKind] | None = None,
substring_matching: bool = False,
within_relative_path: str | None = None,
) -> LanguageServerSymbol:
symbol_candidates = self.find(
name_path_pattern,
include_kinds=include_kinds,
exclude_kinds=exclude_kinds,
substring_matching=substring_matching,
within_relative_path=within_relative_path,
)
if len(symbol_candidates) == 0:
raise ValueError(f"No symbol matching '{name_path_pattern}' found")
if len(symbol_candidates) > 1:
include_rel_path = within_relative_path is not None
raise ValueError(
f"Found multiple {len(symbol_candidates)} symbols matching '{name_path_pattern}'. "
"They are: \n"
+ json.dumps([s.to_dict(kind=True, include_relative_path=include_rel_path) for s in symbol_candidates], indent=2)
)
return symbol_candidates[0]
def find_by_location(self, location: LanguageServerSymbolLocation) -> LanguageServerSymbol | None:
if location.relative_path is None:
return None
@@ -532,28 +553,17 @@ class LanguageServerSymbolRetriever:
exclude_kinds: Sequence[SymbolKind] | None = None,
) -> list[ReferenceInLanguageServerSymbol]:
"""
Find all symbols that reference the symbol with the given name.
If multiple symbols fit the name (e.g. for variables that are overwritten), will use the first one.
Find all symbols that reference the specified symbol, which is assumed to be unique.
:param name_path: the name path of the symbol to find
:param name_path: the name path of the symbol to find. (While this can be a matching pattern, it should
usually be the full path to ensure uniqueness.)
:param relative_file_path: the relative path of the file in which the referenced symbol is defined.
:param include_body: whether to include the body of all symbols in the result.
Not recommended, as the referencing symbols will often be files, and thus the bodies will be very long.
:param include_kinds: which kinds of symbols to include in the result.
:param exclude_kinds: which kinds of symbols to exclude from the result.
"""
symbol_candidates = self.find_by_name(name_path, substring_matching=False, within_relative_path=relative_file_path)
if len(symbol_candidates) == 0:
log.warning(f"No symbol with name {name_path} found in file {relative_file_path}")
return []
if len(symbol_candidates) > 1:
log.error(
f"Found {len(symbol_candidates)} symbols with name {name_path} in file {relative_file_path}."
f"May be an overwritten variable, in which case you can ignore this error. Proceeding with the first one. "
f"Found symbols for {name_path=} in {relative_file_path=}: \n"
f"{json.dumps([s.location.to_dict() for s in symbol_candidates], indent=2)}"
)
symbol = symbol_candidates[0]
symbol = self.find_unique(name_path, substring_matching=False, within_relative_path=relative_file_path)
return self.find_referencing_symbols_by_location(
symbol.location, include_body=include_body, include_kinds=include_kinds, exclude_kinds=exclude_kinds
)
+1 -1
View File
@@ -136,7 +136,7 @@ class FindSymbolTool(Tool, ToolMarkerSymbolicRead):
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()
symbols = symbol_retriever.find_by_name(
symbols = symbol_retriever.find(
name_path_pattern,
include_kinds=parsed_include_kinds,
exclude_kinds=parsed_exclude_kinds,