LS: new method, request_full_symbol_tree

This commit is contained in:
Michael Panchenko
2025-03-25 16:48:18 +01:00
parent 95ea532143
commit d73e725a87
2 changed files with 154 additions and 32 deletions
+112 -3
View File
@@ -660,7 +660,9 @@ class LanguageServer:
:param relative_file_path: The relative path of the file that has the symbols
:param include_body: whether to include the body of the symbols in the result.
:return: A list of symbols in the file, and a list of root symbols that represent the tree structure of the symbols. Each symbol in hierarchy starting from the roots has a children attribute.
:return: A list of symbols in the file, and a list of root symbols that represent the tree structure of the symbols.
Each symbol in hierarchy starting from the roots has a children attribute.
All symbols will have a location and a children attribute.
"""
self.logger.log(f"Requesting document symbols for {relative_file_path} for the first time", logging.DEBUG)
# TODO: it's kinda dumb to not use the cache if include_body is False after include_body was True once
@@ -736,7 +738,92 @@ class LanguageServer:
self._document_symbols_cache[cache_key] = (file_data.content_hash, result)
self._cache_has_changed = True
return result
async def request_full_symbol_tree(self, start_package_relative_path: str | None = None, include_body: bool = False) -> List[multilspy_types.UnifiedSymbolInformation]:
"""
Will go through all files in the project and build a tree of symbols. Note: this may be slow the first time it is called.
For each file, a symbol of kind Module (3) will be created. For directories, a symbol of kind Package (4) will be created.
All symbols will have a children attribute, thereby representing the tree structure of all symbols in the project
that are within the repository.
Will ignore all directories that start with a dot (.) and __pycache__ directories.
Args:
start_package_relative_path: if passed, only the symbols within this directory will be considered.
include_body: whether to include the body of the symbols in the result.
Returns:
A list of root symbols representing the top-level packages/modules in the project.
"""
if not self.server_started:
self.logger.log(
"request_full_symbol_tree called before Language Server started",
logging.ERROR,
)
raise MultilspyException("Language Server not started")
# Helper function to check if a path should be ignored
def should_ignore_path(path: str) -> bool:
parts = path.split(os.sep)
return any(part.startswith('.') or part == '__pycache__' for part in parts)
# Helper function to recursively process directories
async def process_directory(dir_path: str) -> List[multilspy_types.UnifiedSymbolInformation]:
if should_ignore_path(dir_path):
return []
result = []
try:
items = os.listdir(os.path.join(self.repository_root_path, dir_path))
except OSError:
return []
# Create package symbol for directory
package_symbol = multilspy_types.UnifiedSymbolInformation( # type: ignore
name=os.path.basename(dir_path),
kind=multilspy_types.SymbolKind.Package,
location=multilspy_types.Location(
uri=str(pathlib.Path(os.path.join(self.repository_root_path, dir_path)).as_uri()),
range={"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}},
absolutePath=str(os.path.join(self.repository_root_path, dir_path)),
relativePath=str(Path(dir_path).resolve().relative_to(self.repository_root_path)),
),
children=[]
)
result.append(package_symbol)
for item in items:
item_path = os.path.join(dir_path, item)
abs_item_path = os.path.join(self.repository_root_path, item_path)
if os.path.isdir(abs_item_path):
child_symbols = await process_directory(item_path)
package_symbol["children"].extend(child_symbols)
elif os.path.isfile(abs_item_path):
_, root_nodes = await self.request_document_symbols(item_path, include_body=include_body)
# Create module symbol
module_symbol = multilspy_types.UnifiedSymbolInformation( # type: ignore
name=os.path.splitext(item)[0],
kind=multilspy_types.SymbolKind.Module,
location=multilspy_types.Location(
uri=str(pathlib.Path(abs_item_path).as_uri()),
range={"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}},
absolutePath=str(abs_item_path),
relativePath=str(Path(item_path).resolve().relative_to(self.repository_root_path)),
),
children=root_nodes
)
package_symbol["children"].append(module_symbol)
return result
# Start from the root or the specified directory
start_path = start_package_relative_path or self.repository_root_path
return await process_directory(start_path)
async def request_hover(self, relative_file_path: str, line: int, column: int) -> Union[multilspy_types.Hover, None]:
"""
Raise a [textDocument/hover](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_hover) request to the Language Server
@@ -1188,8 +1275,8 @@ class SyncLanguageServer:
:return: None
"""
self.loop = asyncio.new_event_loop()
loop_thread = threading.Thread(target=self.loop.run_forever, daemon=True)
loop_thread.start()
self.loop_thread = threading.Thread(target=self.loop.run_forever, daemon=True)
self.loop_thread.start()
ctx = self.language_server.start_server()
asyncio.run_coroutine_threadsafe(ctx.__aenter__(), loop=self.loop).result()
yield self
@@ -1280,6 +1367,28 @@ class SyncLanguageServer:
self.language_server.request_document_symbols(relative_file_path, include_body), self.loop
).result()
return result
def request_full_symbol_tree(self, start_package_relative_path: str | None = None, include_body: bool = False) -> List[multilspy_types.UnifiedSymbolInformation]:
"""
Will go through all files in the project and build a tree of symbols. Note: this may be slow the first time it is called.
For each file, a symbol of kind Module (3) will be created. For directories, a symbol of kind Package (4) will be created.
All symbols will have a children attribute, thereby representing the tree structure of all symbols in the project
that are within the repository.
Will ignore all directories that start with a dot (.) and __pycache__ directories.
Args:
start_package_relative_path: if passed, only the symbols within this directory will be considered.
include_body: whether to include the body of the symbols in the result.
Returns:
A list of root symbols representing the top-level packages/modules in the project.
"""
result = asyncio.run_coroutine_threadsafe(
self.language_server.request_full_symbol_tree(start_package_relative_path, include_body), self.loop
).result()
return result
def request_hover(self, relative_file_path: str, line: int, column: int) -> Union[multilspy_types.Hover, None]:
"""
+42 -29
View File
@@ -293,6 +293,8 @@ class TestLanguageServerSymbols:
# Step 3: Verify that they refer to the same symbol
assert defining_symbol["kind"] == containing_symbol["kind"]
assert "location" in defining_symbol
assert "location" in containing_symbol
assert defining_symbol["location"]["uri"] == containing_symbol["location"]["uri"]
# The integration test is successful if we've gotten this far,
@@ -320,35 +322,46 @@ class TestLanguageServerSymbols:
warnings.warn("Could not verify container hierarchy - implementation detail")
def test_symbol_tree_structure(self, language_server: SyncLanguageServer, repo_path: Path):
"""Test the symbol tree structure."""
file_path = str(repo_path / "test_repo" / "services.py")
symbols, root_nodes = language_server.request_document_symbols(file_path)
assert len(symbols) > 0
assert {root["name"] for root in root_nodes} == {
"UserService",
"ItemService",
"create_service_container",
"user_var_str",
"user_service",
}
user_service_root = next(root for root in root_nodes if root["name"] == "UserService")
assert user_service_root
assert "children" in user_service_root
assert {child["name"] for child in user_service_root["children"] if child["kind"] != SymbolKind.Variable} == {
"__init__",
"create_user",
"get_user",
"list_users",
"delete_user",
}
"""Test that the symbol tree structure is correctly built."""
# Get all symbols in the test file
repo_structure = language_server.request_full_symbol_tree()
assert len(repo_structure) == 1
# Assert that the root symbol is the test_repo directory
assert repo_structure[0]["name"] == "test_repo"
assert repo_structure[0]["kind"] == SymbolKind.Package
assert "children" in repo_structure[0]
# Assert that the children are the top-level packages
child_names = {child["name"] for child in repo_structure[0]["children"]}
child_kinds = {child["kind"] for child in repo_structure[0]["children"]}
assert child_names == {"test_repo", "custom_test", "examples", "scripts"}
assert child_kinds == {SymbolKind.Package}
examples_package = next(child for child in repo_structure[0]["children"] if child["name"] == "examples")
# assert that children are __init__ and user_management
assert {child["name"] for child in examples_package["children"]} == {"__init__", "user_management"}
assert {child["kind"] for child in examples_package["children"]} == {SymbolKind.Module}
# Now recursively flatten the tree into a set of names and assert that it coincides with the set of symbol names
all_names_in_tree = set()
# assert that tree of user_management node is same as retrieved directly
user_management_node = next(child for child in examples_package["children"] if child["name"] == "user_management")
user_management_rel_path = user_management_node["location"]["relativePath"]
assert user_management_rel_path == "examples/user_management.py"
_, user_management_roots = language_server.request_document_symbols(str(repo_path / "examples" / "user_management.py"))
assert user_management_roots == user_management_node["children"]
def flatten_tree(nodes):
for node in nodes:
all_names_in_tree.add(node["name"])
flatten_tree(node.get("children", []))
def test_symbol_tree_structure_subdir(self, language_server: SyncLanguageServer, repo_path: Path):
"""Test that the symbol tree structure is correctly built."""
# Get all symbols in the test file
examples_package_roots = language_server.request_full_symbol_tree(start_package_relative_path=str(repo_path / "examples"))
assert len(examples_package_roots) == 1
examples_package = examples_package_roots[0]
assert examples_package["name"] == "examples"
assert examples_package["kind"] == SymbolKind.Package
# assert that children are __init__ and user_management
assert {child["name"] for child in examples_package["children"]} == {"__init__", "user_management"}
assert {child["kind"] for child in examples_package["children"]} == {SymbolKind.Module}
flatten_tree(root_nodes)
assert all_names_in_tree == {symbol["name"] for symbol in symbols}
# assert that tree of user_management node is same as retrieved directly
user_management_node = next(child for child in examples_package["children"] if child["name"] == "user_management")
user_management_rel_path = user_management_node["location"]["relativePath"]
assert user_management_rel_path == "examples/user_management.py"
_, user_management_roots = language_server.request_document_symbols(str(repo_path / "examples" / "user_management.py"))
assert user_management_roots == user_management_node["children"]