diff --git a/CHANGELOG.md b/CHANGELOG.md index ec131faa..f4b43d42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,10 @@ Status of the `main` branch. Changes prior to the next official version change w storage directory via a lock; a single instance (including across restarts) still gets the same directory, and a second concurrent instance gets a directory of its own instead of contending for the first one's (#1966) + - Fix: document symbol caching did not account for language-server-specific post-processing of + symbols, which was applied outside the caches; the processing of language servers that post-process + symbols (e.g. Go, Nix, Fortran, F#, Vue) was therefore repeated on every request or, if it mutated + symbols in place, re-applied to already processed cached results CLI: - Fix `project index-file` command not using only the relevant language server to index the given file (#1965) diff --git a/src/solidlsp/language_servers/al_language_server.py b/src/solidlsp/language_servers/al_language_server.py index f3b72500..c869c5ea 100644 --- a/src/solidlsp/language_servers/al_language_server.py +++ b/src/solidlsp/language_servers/al_language_server.py @@ -992,25 +992,19 @@ class ALLanguageServer(SolidLanguageServer): @override def request_document_symbols(self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None) -> DocumentSymbols: - """ - Override to normalize AL symbol names by stripping object type and ID metadata. - - AL Language Server returns symbol names with full object format like - 'Table 50000 "TEST Customer"', but symbol names should be pure without metadata. - This follows the same pattern as Java LS which strips type information from names. - - Metadata (object type, ID) is available via the hover LSP method when using - include_info=True in find_symbol. - """ - # Normalize path separators for cross-platform compatibility (backslash → forward slash) relative_file_path = self._normalize_path(relative_file_path) - - # Get symbols from parent implementation - document_symbols = super().request_document_symbols(relative_file_path, file_buffer=file_buffer) - - return document_symbols + return super().request_document_symbols(relative_file_path, file_buffer=file_buffer) def _normalize_symbol_name(self, symbol: RawDocumentSymbol, relative_file_path: str) -> str: + # Override to normalize AL symbol names by stripping object type and ID metadata. + # IMPORTANT: Update _document_symbols_cache_fingerprint if this normalization logic changes. + # + # AL Language Server returns symbol names with full object format like + # 'Table 50000 "TEST Customer"', but symbol names should be pure without metadata. + # This follows the same pattern as Java LS which strips type information from names. + # + # Metadata (object type, ID) is available via the hover LSP method when using + # include_info=True in find_symbol. original_name = symbol["name"] normalized_name = self._extract_al_display_name(original_name) diff --git a/src/solidlsp/language_servers/angular_language_server.py b/src/solidlsp/language_servers/angular_language_server.py index c25ed837..be16d7d9 100644 --- a/src/solidlsp/language_servers/angular_language_server.py +++ b/src/solidlsp/language_servers/angular_language_server.py @@ -611,22 +611,22 @@ class AngularLanguageServer(SolidLanguageServer): # --------------------------------------------------------------------- @override - def _request_document_symbols( + def _request_raw_document_symbols( self, relative_file_path: str, file_data: LSPFileBuffer | None ) -> list[SymbolInformation] | list[DocumentSymbol] | None: if self._ts_server is not None and self._is_typescript_file(relative_file_path): with self._ts_server.open_file(relative_file_path): - return self._ts_server._request_document_symbols(relative_file_path, file_data=None) + return self._ts_server._request_raw_document_symbols(relative_file_path, file_data=None) # ngserver returns -32601 for textDocument/documentSymbol on every .html file. # Route to the HTML companion which gives the structural element tree # (works on both plain HTML like index.html and Angular templates). if self._is_html_template_file(relative_file_path): if self._html_server is not None and self._html_server_started: with self._html_server.open_file(relative_file_path): - return self._html_server._request_document_symbols(relative_file_path, file_data=None) + return self._html_server._request_raw_document_symbols(relative_file_path, file_data=None) log.debug("HTML companion unavailable for %s; returning None", relative_file_path) return None - return super()._request_document_symbols(relative_file_path, file_data) + return super()._request_raw_document_symbols(relative_file_path, file_data) @override def request_definition(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]: diff --git a/src/solidlsp/language_servers/ansible_language_server.py b/src/solidlsp/language_servers/ansible_language_server.py index 6ebbfe59..c7546e2e 100644 --- a/src/solidlsp/language_servers/ansible_language_server.py +++ b/src/solidlsp/language_servers/ansible_language_server.py @@ -127,7 +127,7 @@ class AnsibleLanguageServer(SolidLanguageServer): return False @override - def _request_document_symbols( + def _request_raw_document_symbols( self, relative_file_path: str, file_data: LSPFileBuffer | None ) -> list[SymbolInformation] | list[DocumentSymbol] | None: # ansible-language-server does not implement textDocument/documentSymbol and the diff --git a/src/solidlsp/language_servers/bash_language_server.py b/src/solidlsp/language_servers/bash_language_server.py index 5d52bbbc..5dce504d 100644 --- a/src/solidlsp/language_servers/bash_language_server.py +++ b/src/solidlsp/language_servers/bash_language_server.py @@ -10,10 +10,8 @@ import threading from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection, build_npm_install_command from solidlsp.ls import ( - DocumentSymbols, LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, - LSPFileBuffer, SolidLanguageServer, ) from solidlsp.ls_config import LanguageServerConfig @@ -297,22 +295,3 @@ class BashLanguageServer(SolidLanguageServer): self.server_ready.set() else: log.info("Bash server initialization complete") - - def request_document_symbols(self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None) -> DocumentSymbols: - # Uses the standard LSP documentSymbol request which provides reliable function detection - # for all bash function syntaxes including: - # - function name() { ... } (with function keyword) - # - name() { ... } (traditional syntax) - # - Functions with various indentation levels - # - Functions with comments before/after/inside - - log.debug(f"Requesting document symbols via LSP for {relative_file_path}") - - # Use the standard LSP approach - bash-language-server handles all function syntaxes correctly - document_symbols = super().request_document_symbols(relative_file_path, file_buffer=file_buffer) - - # Log detection results for debugging - functions = [s for s in document_symbols.iter_symbols() if s.get("kind") == 12] - log.info(f"LSP function detection for {relative_file_path}: Found {len(functions)} functions") - - return document_symbols diff --git a/src/solidlsp/language_servers/eclipse_jdtls.py b/src/solidlsp/language_servers/eclipse_jdtls.py index baa45de9..d5904ad8 100644 --- a/src/solidlsp/language_servers/eclipse_jdtls.py +++ b/src/solidlsp/language_servers/eclipse_jdtls.py @@ -1406,10 +1406,10 @@ class EclipseJDTLS(SolidLanguageServer): return best_result - def _request_document_symbols( + def _request_raw_document_symbols( self, relative_file_path: str, file_data: LSPFileBuffer | None ) -> list[SymbolInformation] | list[DocumentSymbol] | None: - result = super()._request_document_symbols(relative_file_path, file_data=file_data) + result = super()._request_raw_document_symbols(relative_file_path, file_data=file_data) if result is None: return None diff --git a/src/solidlsp/language_servers/fortran_language_server.py b/src/solidlsp/language_servers/fortran_language_server.py index 134460ab..3decb5ec 100644 --- a/src/solidlsp/language_servers/fortran_language_server.py +++ b/src/solidlsp/language_servers/fortran_language_server.py @@ -4,6 +4,7 @@ Fortran Language Server implementation using fortls. import logging import re +from collections.abc import Hashable from overrides import override @@ -139,9 +140,16 @@ class FortranLanguageServer(SolidLanguageServer): return symbol @override - def request_document_symbols(self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None) -> DocumentSymbols: + def _document_symbols_cache_fingerprint(self) -> Hashable | None: + build_document_symbols_version = 2 + return build_document_symbols_version + + @override + def _build_document_symbols_from_raw_symbols(self, relative_file_path: str, file_buffer: LSPFileBuffer) -> DocumentSymbols: # Override to fix fortls's incorrect selectionRange bug. # + # IMPORTANT: Update _document_symbols_cache_fingerprint() when changing this method. + # # fortls returns selectionRange pointing to line start (character 0) instead of the # identifier name position. This breaks MCP server features that rely on exact positions. # @@ -152,11 +160,10 @@ class FortranLanguageServer(SolidLanguageServer): # 4. Returns corrected symbols # Get symbols from fortls (with incorrect selectionRange) - document_symbols = super().request_document_symbols(relative_file_path, file_buffer=file_buffer) + document_symbols = super()._build_document_symbols_from_raw_symbols(relative_file_path, file_buffer=file_buffer) # Get file content for parsing - with self.open_file(relative_file_path) as file_data: - file_content = file_data.contents + file_content = file_buffer.contents # Fix selectionRange recursively for all symbols def fix_symbol_and_children(symbol: ls_types.UnifiedSymbolInformation) -> ls_types.UnifiedSymbolInformation: diff --git a/src/solidlsp/language_servers/fsharp_language_server.py b/src/solidlsp/language_servers/fsharp_language_server.py index 660c4335..0303661b 100644 --- a/src/solidlsp/language_servers/fsharp_language_server.py +++ b/src/solidlsp/language_servers/fsharp_language_server.py @@ -8,6 +8,7 @@ import re import shutil import subprocess import threading +from collections.abc import Hashable from pathlib import Path from overrides import override @@ -110,14 +111,19 @@ class FSharpLanguageServer(SolidLanguageServer): return corrected_symbol @override - def request_document_symbols(self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None) -> DocumentSymbols: + def _document_symbols_cache_fingerprint(self) -> Hashable | None: + build_document_symbols_impl_version = 2 + return build_document_symbols_impl_version + + @override + def _build_document_symbols_from_raw_symbols(self, relative_file_path: str, file_buffer: LSPFileBuffer) -> DocumentSymbols: # Override to fix FsAutoComplete's incorrect selectionRange for module declarations (#925): # it points at the `module` keyword instead of the module name, so hover-by-selectionRange # returns the keyword's docs instead of the module's own. - document_symbols = super().request_document_symbols(relative_file_path, file_buffer=file_buffer) + # IMPORTANT: Update _document_symbols_cache_fingerprint() when changing this method. + document_symbols = super()._build_document_symbols_from_raw_symbols(relative_file_path, file_buffer=file_buffer) - with self.open_file(relative_file_path) as file_data: - file_content = file_data.contents + file_content = file_buffer.contents def fix_symbol_and_children(symbol: ls_types.UnifiedSymbolInformation) -> ls_types.UnifiedSymbolInformation: fixed = self._fix_module_selection_range(symbol, file_content) diff --git a/src/solidlsp/language_servers/gopls.py b/src/solidlsp/language_servers/gopls.py index 1dccdb0c..4918a33c 100644 --- a/src/solidlsp/language_servers/gopls.py +++ b/src/solidlsp/language_servers/gopls.py @@ -170,8 +170,8 @@ class Gopls(SolidLanguageServer): @override def _document_symbols_cache_fingerprint(self) -> Hashable: normalize_symbol_name_version = 1 - request_document_symbols_impl_version = 2 - return normalize_symbol_name_version, request_document_symbols_impl_version + build_document_symbols_impl_version = 3 + return normalize_symbol_name_version, build_document_symbols_impl_version @override def _raw_document_symbols_cache_fingerprint(self) -> Hashable: @@ -208,13 +208,14 @@ class Gopls(SolidLanguageServer): return symbol["name"].rsplit(".", 1)[-1] @override - def request_document_symbols(self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None) -> DocumentSymbols: + def _build_document_symbols_from_raw_symbols(self, relative_file_path: str, file_buffer: LSPFileBuffer) -> DocumentSymbols: # Override to extend single `type`/`var`/`const` declaration ranges to include the leading # keyword. gopls excludes the keyword from such ranges (unlike `func` declarations), which # causes replace_symbol_body to drop the keyword from the symbol body and replacement range; # a natural keyword-inclusive round-trip edit would then corrupt the file (e.g. `type Foo` # becomes `type type Foo`). See _extend_go_symbol_range_to_include_leading_keyword. - document_symbols = super().request_document_symbols(relative_file_path, file_buffer=file_buffer) + # IMPORTANT: Update _document_symbols_cache_fingerprint() when changing this method. + document_symbols = super()._build_document_symbols_from_raw_symbols(relative_file_path, file_buffer=file_buffer) if not document_symbols.root_symbols: return document_symbols diff --git a/src/solidlsp/language_servers/marksman.py b/src/solidlsp/language_servers/marksman.py index 98696f53..59d30110 100644 --- a/src/solidlsp/language_servers/marksman.py +++ b/src/solidlsp/language_servers/marksman.py @@ -169,16 +169,16 @@ class Marksman(SolidLanguageServer): return request_document_symbols_override_version @override - def request_document_symbols(self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None) -> DocumentSymbols: - """Override to remap Marksman's heading symbol kinds from String to Namespace. + def _build_document_symbols_from_raw_symbols(self, relative_file_path: str, file_buffer: LSPFileBuffer) -> DocumentSymbols: + # Override to remap Marksman's heading symbol kinds from String to Namespace. + # + # Marksman LSP returns all markdown headings (h1-h6) with SymbolKind.String (15). + # This is problematic because String (15) >= Variable (13), so headings are + # classified as "low-level" and filtered out of symbol overviews. + # Remapping to Namespace (3) fixes this and is semantically appropriate + # (headings are named sections containing other content). - Marksman LSP returns all markdown headings (h1-h6) with SymbolKind.String (15). - This is problematic because String (15) >= Variable (13), so headings are - classified as "low-level" and filtered out of symbol overviews. - Remapping to Namespace (3) fixes this and is semantically appropriate - (headings are named sections containing other content). - """ - document_symbols = super().request_document_symbols(relative_file_path, file_buffer=file_buffer) + document_symbols = super()._build_document_symbols_from_raw_symbols(relative_file_path, file_buffer=file_buffer) # NOTE: When changing this method, also update the cache fingerprint method above diff --git a/src/solidlsp/language_servers/matlab_language_server.py b/src/solidlsp/language_servers/matlab_language_server.py index d6694355..dc44c4c9 100644 --- a/src/solidlsp/language_servers/matlab_language_server.py +++ b/src/solidlsp/language_servers/matlab_language_server.py @@ -494,7 +494,7 @@ class MatlabLanguageServer(SolidLanguageServer): "helperFiles", # Common helper file directories ] - def _request_document_symbols( + def _request_raw_document_symbols( self, relative_file_path: str, file_data: LSPFileBuffer | None ) -> list[SymbolInformation] | list[DocumentSymbol] | None: """ @@ -504,7 +504,7 @@ class MatlabLanguageServer(SolidLanguageServer): particularly for script sections (cell mode markers like %%). This method normalizes the names to strings for compatibility with the unified symbol format. """ - symbols = super()._request_document_symbols(relative_file_path, file_data) + symbols = super()._request_raw_document_symbols(relative_file_path, file_data) if symbols is None or len(symbols) == 0: return symbols diff --git a/src/solidlsp/language_servers/nixd_ls.py b/src/solidlsp/language_servers/nixd_ls.py index 00ab995e..9e748ee5 100644 --- a/src/solidlsp/language_servers/nixd_ls.py +++ b/src/solidlsp/language_servers/nixd_ls.py @@ -10,6 +10,7 @@ import logging import platform import shutil import subprocess +from collections.abc import Hashable from copy import deepcopy from pathlib import Path from typing import Any @@ -184,16 +185,22 @@ class NixLanguageServer(SolidLanguageServer): return symbol @override - def request_document_symbols(self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None) -> DocumentSymbols: + def _document_symbols_cache_fingerprint(self) -> Hashable | None: + build_document_symbols_version = 2 + return build_document_symbols_version + + @override + def _build_document_symbols_from_raw_symbols(self, relative_file_path: str, file_buffer: LSPFileBuffer) -> DocumentSymbols: # Override to extend Nix symbol ranges to include trailing semicolons. # nixd provides expression-level ranges (excluding semicolons) but serena needs # statement-level ranges (including semicolons) for proper symbol replacement. + # IMPORTANT: Update _document_symbols_cache_fingerprint() when changing this method. # Get symbols from parent implementation - document_symbols = super().request_document_symbols(relative_file_path, file_buffer=file_buffer) + document_symbols = super()._build_document_symbols_from_raw_symbols(relative_file_path, file_buffer=file_buffer) # Get file content for range extension - file_content = self.language_server.retrieve_full_file_content(relative_file_path) + file_content = file_buffer.contents # Extend ranges for all symbols recursively def extend_symbol_and_children(symbol: ls_types.UnifiedSymbolInformation) -> ls_types.UnifiedSymbolInformation: diff --git a/src/solidlsp/language_servers/vue_language_server.py b/src/solidlsp/language_servers/vue_language_server.py index 74519276..0a968d71 100644 --- a/src/solidlsp/language_servers/vue_language_server.py +++ b/src/solidlsp/language_servers/vue_language_server.py @@ -176,6 +176,7 @@ class VueLanguageServer(SolidLanguageServer): ProcessLaunchInfo(cmd=vue_lsp_executable_path, cwd=repository_root_path), "vue", solidlsp_settings, + cache_version_raw_document_symbols=2, ) self.server_ready = threading.Event() self.initialize_searcher_command_available = threading.Event() @@ -868,7 +869,7 @@ class VueLanguageServer(SolidLanguageServer): return prefer_non_node_modules_definition(definitions) @override - def _request_document_symbols( + def _request_raw_document_symbols( self, relative_file_path: str, file_data: LSPFileBuffer | None ) -> list[SymbolInformation] | list[DocumentSymbol] | None: """ @@ -883,7 +884,7 @@ class VueLanguageServer(SolidLanguageServer): We filter out Property symbols that have a matching Variable with the same name at a different location (the definition), keeping only the definition. """ - symbols = super()._request_document_symbols(relative_file_path, file_data) + symbols = super()._request_raw_document_symbols(relative_file_path, file_data) if symbols is None or len(symbols) == 0: return symbols diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 13497121..50a37300 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -1842,24 +1842,15 @@ class SolidLanguageServer(ABC): return [json.loads(json_repr) for json_repr in set(json.dumps(item, sort_keys=True) for item in completions_list)] - def _request_document_symbols( + def _get_raw_document_symbols( self, relative_file_path: str, file_data: LSPFileBuffer | None ) -> list[SymbolInformation] | list[DocumentSymbol] | None: """ - Sends a [documentSymbol](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_documentSymbol) - request to the language server to find symbols in the given file - or returns a cached result if available. - The returned symbols are considered "raw document symbols" (in contrast to processed symbols returned by `request_document_symbols`). + Gets the raw document symbols for the given file, either from the cache or by querying the language server. - NOTE: This method can be overridden in subclasses to post-process the raw results. - When doing so after the initial implementation, be sure to update the init parameter `cache_version_raw_document_symbols` - to a different version (add 1) to ensure that all caches are invalidated appropriately. - IMPORTANT: Since rebuilding the raw document symbol cache from the language server results - is potentially expensive, prefer overriding the `request_document_symbols` method - if the post-processing can also be done on the processed/high-level symbols. - - :param relative_file_path: the relative path of the file that has the symbols. + :param relative_file_path: the relative path of the file for which to retrieve the raw document symbols. :param file_data: the file data buffer, if already opened. If None, the file will be opened in this method. - :return: the list of root symbols in the file. + :return: the list of root symbols in the file """ def get_cached_raw_document_symbols(cache_key: str, fd: LSPFileBuffer) -> list[SymbolInformation] | list[DocumentSymbol] | None: @@ -1879,7 +1870,7 @@ class SolidLanguageServer(ABC): log.debug("perf: raw_document_symbols_cache STALE path=%s", relative_file_path) return None - def get_raw_document_symbols(fd: LSPFileBuffer) -> list[SymbolInformation] | list[DocumentSymbol] | None: + with self._open_file_context(relative_file_path, file_buffer=file_data, open_in_ls=False) as fd: # check for cached result cache_key = relative_file_path response = get_cached_raw_document_symbols(cache_key, fd) @@ -1887,8 +1878,7 @@ class SolidLanguageServer(ABC): return response # no cached result, query language server - log.debug(f"Requesting document symbols for {relative_file_path} from the Language Server") - response = self.server.send.document_symbol({"textDocument": {"uri": self._resolve_file_uri(relative_file_path)}}) + response = self._request_raw_document_symbols(relative_file_path, file_data=fd) # Only cache non-empty results. An empty or None response can occur when the language server # has not yet finished indexing or building the project (e.g. Lean 4 before `lake build`), @@ -1899,8 +1889,30 @@ class SolidLanguageServer(ABC): return response - with self._open_file_context(relative_file_path, file_buffer=file_data) as fd: - return get_raw_document_symbols(fd) + def _request_raw_document_symbols( + self, relative_file_path: str, file_data: LSPFileBuffer | None + ) -> list[SymbolInformation] | list[DocumentSymbol] | None: + """ + Sends a [documentSymbol](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_documentSymbol) + request to the language server to find symbols in the given file. + The returned symbols are considered "raw document symbols" (in contrast to processed symbols returned by `request_document_symbols`). + + NOTE: This method can be overridden in subclasses to post-process the raw results. + When doing so after the initial implementation, be sure to update the init parameter `cache_version_raw_document_symbols` + to a different version (add 1) to ensure that all caches are invalidated appropriately. + IMPORTANT: Since rebuilding the raw document symbol cache from the language server results + is potentially expensive, prefer overriding the `_build_document_symbols_from_raw_symbols` method + if the post-processing can also be done on the processed/high-level symbols. + For symbol name normalization, override `_normalize_symbol_name` instead. + + :param relative_file_path: the relative path of the file that has the symbols. + :param file_data: the file data buffer, if already opened. If None, the file will be opened in this method. + :return: the list of root symbols in the file. + """ + with self._open_file_context(relative_file_path, file_buffer=file_data): + log.debug(f"Requesting document symbols for {relative_file_path} from the Language Server") + response = self.server.send.document_symbol({"textDocument": {"uri": self._resolve_file_uri(relative_file_path)}}) + return response def _normalize_symbol_name(self, symbol: RawDocumentSymbol, relative_file_path: str) -> str: """ @@ -1928,11 +1940,6 @@ class SolidLanguageServer(ABC): """ Retrieves the collection of symbols in the given file. - NOTE: This method can be overridden in subclasses to post-process the results. - When doing so after the initial LS implementation, be sure to also override `_document_symbols_cache_fingerprint` - to ensure that the caches are invalidated appropriately. - DO NOT override this method to modify symbol names; override `_normalize_symbol_name` instead. - :param relative_file_path: The relative path of the file that has the symbols :param file_buffer: an optional file buffer if the file is already opened. :return: the collection of symbols in the file. @@ -1956,97 +1963,8 @@ class SolidLanguageServer(ABC): log.debug("Cached document symbol content for %s has changed (old hash=%s)", relative_file_path, file_hash) - # no cached result: request the root symbols from the language server - root_symbols = self._request_document_symbols(relative_file_path, file_data) - - if root_symbols is None: - log.warning( - f"Received None response from the Language Server for document symbols in {relative_file_path}. " - f"This means the language server can't understand this file (possibly due to syntax errors). It may also be due to a bug or misconfiguration of the LS. " - f"Returning empty list", - ) - return DocumentSymbols([]) - - assert isinstance(root_symbols, list), f"Unexpected response from Language Server: {root_symbols}" - log.debug("Received %d root symbols for %s from the language server", len(root_symbols), relative_file_path) - - body_factory = SymbolBodyFactory(file_data) - - def convert_to_unified_symbol(original_symbol_dict: RawDocumentSymbol) -> ls_types.UnifiedSymbolInformation: - """ - Converts the given symbol dictionary to the unified representation, ensuring - that all required fields are present (except 'children' which is handled separately). - - :param original_symbol_dict: the item to augment - :return: the augmented item (new object) - """ - # noinspection PyInvalidCast - item = cast(ls_types.UnifiedSymbolInformation, dict(original_symbol_dict)) - absolute_path = os.path.join(self.repository_root_path, relative_file_path) - - # handle missing location and path entries - if "location" not in item: - uri = pathlib.Path(absolute_path).as_uri() - assert "range" in item - tree_location = ls_types.Location( - uri=uri, - range=item["range"], - absolutePath=absolute_path, - relativePath=relative_file_path, - ) - item["location"] = tree_location - location = item["location"] - if "absolutePath" not in location: - location["absolutePath"] = absolute_path - if "relativePath" not in location: - location["relativePath"] = relative_file_path - - item["body"] = self.create_symbol_body(item, factory=body_factory) - - # handle missing selectionRange - if "selectionRange" not in item: - if "range" in item: - item["selectionRange"] = item["range"] - else: - item["selectionRange"] = item["location"]["range"] - - return item - - def convert_symbols_with_common_parent( - symbols: list[DocumentSymbol] | list[SymbolInformation], - parent: ls_types.UnifiedSymbolInformation | None, - ) -> list[ls_types.UnifiedSymbolInformation]: - """ - Converts the given symbols into UnifiedSymbolInformation with proper parent-child relationships, - adding overload indices for symbols with the same name under the same parent. - """ - # apply name normalization and count occurrences of each symbol name - total_name_counts: dict[str, int] = defaultdict(lambda: 0) - for symbol in symbols: - name = self._normalize_symbol_name(symbol, relative_file_path=relative_file_path) - symbol["name"] = name - total_name_counts[name] += 1 - - # convert symbols to the unified representation and - # * add overload indices where necessary - # * ensure that the "parent" field is set correctly - name_counts: dict[str, int] = defaultdict(lambda: 0) - unified_symbols = [] - for symbol in symbols: - usymbol = convert_to_unified_symbol(symbol) - if total_name_counts[usymbol["name"]] > 1: - usymbol["overload_idx"] = name_counts[usymbol["name"]] - name_counts[usymbol["name"]] += 1 - usymbol["parent"] = parent - if "children" in usymbol: - usymbol["children"] = convert_symbols_with_common_parent(usymbol["children"], usymbol) # type: ignore - else: - usymbol["children"] = [] - unified_symbols.append(usymbol) - return unified_symbols - - unified_root_symbols = convert_symbols_with_common_parent(root_symbols, None) - document_symbols = DocumentSymbols(unified_root_symbols) + # no cached result: get the raw root symbols from the language server + document_symbols = self._build_document_symbols_from_raw_symbols(relative_file_path, file_buffer=file_data) # update cache content_hash = file_data.content_hash @@ -2056,6 +1974,112 @@ class SolidLanguageServer(ABC): return document_symbols + def _build_document_symbols_from_raw_symbols(self, relative_file_path: str, file_buffer: LSPFileBuffer) -> DocumentSymbols: + """ + Requests the raw symbols from the language server and builds the collection of (high-level) symbols from them. + + NOTE: This method can be overridden in subclasses to post-process the results. + When doing so after the initial LS implementation, be sure to also override `_document_symbols_cache_fingerprint` + to ensure that the caches are invalidated appropriately. + DO NOT override this method to modify symbol names; override `_normalize_symbol_name` instead. + + :param relative_file_path: the relative path of the file + :param file_buffer: file buffer of the file + :return: the collection of symbols in the file. + """ + root_symbols = self._get_raw_document_symbols(relative_file_path, file_data=file_buffer) + + if root_symbols is None: + log.warning( + f"Received None response from the Language Server for document symbols in {relative_file_path}. " + f"This means the language server can't understand this file (possibly due to syntax errors). It may also be due to a bug or misconfiguration of the LS. " + f"Returning empty list", + ) + return DocumentSymbols([]) + + assert isinstance(root_symbols, list), f"Unexpected response from Language Server: {root_symbols}" + log.debug("Received %d root symbols for %s from the language server", len(root_symbols), relative_file_path) + + body_factory = SymbolBodyFactory(file_buffer) + + def convert_to_unified_symbol(original_symbol_dict: RawDocumentSymbol) -> ls_types.UnifiedSymbolInformation: + """ + Converts the given symbol dictionary to the unified representation, ensuring + that all required fields are present (except 'children' which is handled separately). + + :param original_symbol_dict: the item to augment + :return: the augmented item (new object) + """ + # noinspection PyInvalidCast + item = cast(ls_types.UnifiedSymbolInformation, dict(original_symbol_dict)) + absolute_path = os.path.join(self.repository_root_path, relative_file_path) + + # handle missing location and path entries + if "location" not in item: + uri = pathlib.Path(absolute_path).as_uri() + assert "range" in item + tree_location = ls_types.Location( + uri=uri, + range=item["range"], + absolutePath=absolute_path, + relativePath=relative_file_path, + ) + item["location"] = tree_location + location = item["location"] + if "absolutePath" not in location: + location["absolutePath"] = absolute_path + if "relativePath" not in location: + location["relativePath"] = relative_file_path + + item["body"] = self.create_symbol_body(item, factory=body_factory) + + # handle missing selectionRange + if "selectionRange" not in item: + if "range" in item: + item["selectionRange"] = item["range"] + else: + item["selectionRange"] = item["location"]["range"] + + return item + + def convert_symbols_with_common_parent( + symbols: list[DocumentSymbol] | list[SymbolInformation], + parent: ls_types.UnifiedSymbolInformation | None, + ) -> list[ls_types.UnifiedSymbolInformation]: + """ + Converts the given symbols into UnifiedSymbolInformation with proper parent-child relationships, + adding overload indices for symbols with the same name under the same parent. + """ + # apply name normalization and count occurrences of each symbol name + total_name_counts: dict[str, int] = defaultdict(lambda: 0) + for symbol in symbols: + name = self._normalize_symbol_name(symbol, relative_file_path=relative_file_path) + symbol["name"] = name + total_name_counts[name] += 1 + + # convert symbols to the unified representation and + # * add overload indices where necessary + # * ensure that the "parent" field is set correctly + name_counts: dict[str, int] = defaultdict(lambda: 0) + unified_symbols = [] + for symbol in symbols: + usymbol = convert_to_unified_symbol(symbol) + if total_name_counts[usymbol["name"]] > 1: + usymbol["overload_idx"] = name_counts[usymbol["name"]] + name_counts[usymbol["name"]] += 1 + usymbol["parent"] = parent + if "children" in usymbol: + usymbol["children"] = convert_symbols_with_common_parent(usymbol["children"], usymbol) # type: ignore + else: + usymbol["children"] = [] + unified_symbols.append(usymbol) + return unified_symbols + + unified_root_symbols = convert_symbols_with_common_parent(root_symbols, None) + document_symbols = DocumentSymbols(unified_root_symbols) + + return document_symbols + def request_full_symbol_tree(self, within_relative_path: str | None = None) -> list[ls_types.UnifiedSymbolInformation]: """ Will go through all files in the project or within a relative path and build a tree of symbols. diff --git a/test/solidlsp/ansible/test_ansible_basic.py b/test/solidlsp/ansible/test_ansible_basic.py index c6326a57..c9adaa2c 100644 --- a/test/solidlsp/ansible/test_ansible_basic.py +++ b/test/solidlsp/ansible/test_ansible_basic.py @@ -23,7 +23,7 @@ def test_request_document_symbols_returns_none_without_contacting_server() -> No to None instead of sending a request that is guaranteed to fail. Runs with no language server process, node, or npm required. """ - assert AnsibleLanguageServer._request_document_symbols(None, "playbook.yml", None) is None + assert AnsibleLanguageServer._request_raw_document_symbols(None, "playbook.yml", None) is None @pytest.mark.skipif( diff --git a/test/solidlsp/fsharp/test_fsharp_module_selection_range.py b/test/solidlsp/fsharp/test_fsharp_module_selection_range.py index a3b22e85..8ede595b 100644 --- a/test/solidlsp/fsharp/test_fsharp_module_selection_range.py +++ b/test/solidlsp/fsharp/test_fsharp_module_selection_range.py @@ -2,17 +2,13 @@ Regression test for #925: FsAutoComplete's selectionRange for a `module ` declaration points at the `module` keyword rather than at ``, so hovering a module reports the generic keyword's docs instead of the module's own (confirmed by dsyme and MischaPanch in the -issue thread). This pins FSharpLanguageServer._fix_module_selection_range and the -request_document_symbols override directly, without spinning up FsAutoComplete: F# language +issue thread). This pins FSharpLanguageServer._fix_module_selection_range directly, without spinning up FsAutoComplete: F# language server tests are unconditionally disabled (test/conftest.py, category 1, "F# language server is currently unreliable"), so a live-LS test can never run here or on CI. """ -from contextlib import contextmanager - from solidlsp import ls_types from solidlsp.language_servers.fsharp_language_server import FSharpLanguageServer -from solidlsp.ls import DocumentSymbols, SolidLanguageServer def _bare_fsharp_server() -> FSharpLanguageServer: @@ -98,35 +94,3 @@ class TestFixModuleSelectionRange: fixed = server._fix_module_selection_range(symbol, file_content) assert fixed is symbol - - -class TestRequestDocumentSymbolsWiring: - def test_fixes_every_symbol_in_the_tree_not_just_roots(self, monkeypatch) -> None: - """End-to-end wiring test: the override must fix nested symbols too, mirroring the - already-merged fortran_language_server.py recursive-fix pattern this follows. - """ - outer = _module_symbol("Outer", line=0, start_char=0, end_char=6) - inner = _module_symbol("Inner", line=1, start_char=4, end_char=10) - outer["children"] = [inner] - - file_content = "module Outer\n module Inner =\n let x = 1\n" - - def fake_super_request_document_symbols(self, relative_file_path, file_buffer=None): - return DocumentSymbols([outer]) - - monkeypatch.setattr(SolidLanguageServer, "request_document_symbols", fake_super_request_document_symbols) - - server = _bare_fsharp_server() - - @contextmanager - def fake_open_file(relative_file_path): - yield type("FakeFileData", (), {"contents": file_content})() - - monkeypatch.setattr(server, "open_file", fake_open_file) - - result = server.request_document_symbols("Test.fs") - - root = result.root_symbols[0] - assert root["selectionRange"]["start"] == {"line": 0, "character": 7} - child = root["children"][0] - assert child["selectionRange"]["start"] == {"line": 1, "character": 11}