Fix document symbol caching for language servers that post-process symbols

Caching happened in the base implementation before subclass overrides could
post-process the result, so an override's work was either never cached or,
if it mutated in place, re-applied to cached output on every hit.

Restructure both symbol caches so that they wrap the subclass logic:
subclasses now post-process via dedicated hooks (`_request_raw_document_symbols`
and `_build_document_symbols_from_raw_symbols`), and the cached value is
the fully processed result. Cache versions are bumped where the cached
content changes as a consequence.
This commit is contained in:
Dominik Jain authored and Dr. Dominik Jain committed 2026-09-06 13:51:53 +02:00
1 parent be609b6257
commit 9f9db76622
16 files changed
+211 -224

No files matched your search

+4
View File
@@ -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)
@@ -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)
@@ -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]:
@@ -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
@@ -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
@@ -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
@@ -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:
@@ -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)
+5 -4
View File
@@ -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
+9 -9
View File
@@ -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
@@ -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
+10 -3
View File
@@ -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:
@@ -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
+138 -114
View File
@@ -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.
+1 -1
View File
@@ -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(
@@ -2,17 +2,13 @@
Regression test for #925: FsAutoComplete's selectionRange for a `module <Name>` declaration
points at the `module` keyword rather than at `<Name>`, 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}