From d73e725a8776bf072a36033342203ca71d2f5dae Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Tue, 25 Mar 2025 16:48:18 +0100 Subject: [PATCH] LS: new method, request_full_symbol_tree --- src/multilspy/language_server.py | 115 +++++++++++++++++++++++- test/multilspy/test_symbol_retrieval.py | 71 +++++++++------ 2 files changed, 154 insertions(+), 32 deletions(-) diff --git a/src/multilspy/language_server.py b/src/multilspy/language_server.py index 8ed1cfa..625dd46 100644 --- a/src/multilspy/language_server.py +++ b/src/multilspy/language_server.py @@ -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]: """ diff --git a/test/multilspy/test_symbol_retrieval.py b/test/multilspy/test_symbol_retrieval.py index 2ca9256..74335a0 100644 --- a/test/multilspy/test_symbol_retrieval.py +++ b/test/multilspy/test_symbol_retrieval.py @@ -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"]