diff --git a/src/serena/util/text_utils.py b/src/serena/util/text_utils.py index e3c63e77..8f10fe0b 100644 --- a/src/serena/util/text_utils.py +++ b/src/serena/util/text_utils.py @@ -523,3 +523,35 @@ class ContentReplacer: "Please revise the expression to be more specific or enable allow_multiple_occurrences if this is expected." ) return updated_content + + +@dataclass +class TextCoords: + line: int + """ + 0-based line number + """ + col: int + """ + 0-based column number + """ + + +def find_text_coordinates(content: str, regex: str) -> TextCoords | None: + """ + Finds the line and column number of the first match of a regex pattern in the given content. + + :param content: the text content to search through + :param regex: the regular expression pattern to search for; it must match part of a single line, + and contain exactly one group that captures the position of interest (e.g., the exact variable name to find the coordinates of) + :return: a tuple of (line_number, column_number) for the first match found, both 0-based, or None if no match is found + """ + pattern = re.compile(regex) + for line_number, line in enumerate(content.splitlines()): + match = pattern.search(line) + if match: + if len(match.groups()) != 1: + raise ValueError(f"Regex must contain exactly one group to capture the position, but found {len(match.groups())} groups.") + column_number = match.start(1) + return TextCoords(line_number, column_number) + return None diff --git a/src/solidlsp/language_servers/vue_language_server.py b/src/solidlsp/language_servers/vue_language_server.py index e810226c..3ded6210 100644 --- a/src/solidlsp/language_servers/vue_language_server.py +++ b/src/solidlsp/language_servers/vue_language_server.py @@ -21,7 +21,7 @@ from solidlsp.language_servers.typescript_language_server import ( prefer_non_node_modules_definition, ) from solidlsp.ls import LSPFileBuffer, SolidLanguageServer -from solidlsp.ls_config import Language, LanguageServerConfig +from solidlsp.ls_config import FilenameMatcher, Language, LanguageServerConfig from solidlsp.ls_exceptions import SolidLSPException from solidlsp.ls_types import Location from solidlsp.ls_utils import PathUtils @@ -47,6 +47,11 @@ class VueTypeScriptServer(TypeScriptLanguageServer): """ return Language.TYPESCRIPT + def get_source_fn_matcher(self) -> FilenameMatcher: + # must override with Vue-specific matcher to ensure .vue files are included (as they can be discovered via references, + # for instance; otherwise, we may find references in .vue files but then filter the results out, because .vue files are ignored.) + return Language.VUE.get_source_fn_matcher() + class DependencyProvider(TypeScriptLanguageServer.DependencyProvider): override_ts_ls_executable: str | None = None diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index e37a1abc..134d5c36 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -23,7 +23,7 @@ from sensai.util.string import ToStringMixin from serena.util.file_system import match_path from serena.util.text_utils import MatchedConsecutiveLines from solidlsp import ls_types -from solidlsp.ls_config import Language, LanguageServerConfig +from solidlsp.ls_config import FilenameMatcher, Language, LanguageServerConfig from solidlsp.ls_exceptions import SolidLSPException from solidlsp.ls_process import LanguageServerProcess from solidlsp.ls_types import UnifiedSymbolInformation @@ -35,6 +35,7 @@ from solidlsp.lsp_protocol_handler.lsp_types import ( Definition, DefinitionParams, DocumentSymbol, + ImplementationParams, LocationLink, RenameParams, SymbolInformation, @@ -378,6 +379,13 @@ class SolidLanguageServer(ABC): def get_language_enum_instance(cls) -> Language: return Language.from_ls_class(cls) + @classmethod + def supports_implementation_request(cls) -> bool: + """ + Return whether this language server supports ``textDocument/implementation``. + """ + return False + @classmethod def ls_resources_dir(cls, solidlsp_settings: SolidLSPSettings, mkdir: bool = True) -> str: """ @@ -575,6 +583,14 @@ class SolidLanguageServer(ABC): """ return self._ignore_spec + def get_source_fn_matcher(self) -> FilenameMatcher: + """ + :return: the source filename matcher for this language server, which must positively match all files that + are understood by this language server or are discovered as containing sources indirectly, e.g. via references + """ + # By default, use the matcher of the language + return self.language.get_source_fn_matcher() + def is_ignored_path(self, relative_path: str, ignore_unsupported_files: bool = True) -> bool: """ Determine if a path should be ignored based on file type @@ -592,7 +608,7 @@ class SolidLanguageServer(ABC): # Check file extension if it's a file is_file = os.path.isfile(abs_path) if is_file and ignore_unsupported_files: - fn_matcher = self.language.get_source_fn_matcher() + fn_matcher = self.get_source_fn_matcher() if not fn_matcher.is_relevant_filename(abs_path): return True @@ -846,6 +862,149 @@ class SolidLanguageServer(ABC): def _send_definition_request(self, definition_params: DefinitionParams) -> Definition | list[LocationLink] | None: return self.server.send.definition(definition_params) + class SymbolLocationRequest(ABC): + def __init__( + self, + language_server: "SolidLanguageServer", + relative_file_path: str, + line: int, + column: int, + *, + request_name: str, + ) -> None: + self.language_server = language_server + self.relative_file_path = relative_file_path + self.line = line + self.column = column + self.request_name = request_name + self.skip_ignored_paths = True + + def execute(self) -> list[ls_types.Location]: + self._ensure_server_started() + + t0 = perf_counter() if _debug_enabled else None + with self.language_server.open_file(self.relative_file_path): + self.language_server._wait_for_cross_file_references_if_needed() + try: + response = self.send_request() + except Exception as e: + mapped_exception = self.map_exception(e) + if mapped_exception is not None: + raise mapped_exception from e + raise + + result = self.normalize_response(response) + if t0 is not None: + self.log_perf_result(t0, result) + return result + + def _ensure_server_started(self) -> None: + if not self.language_server.server_started: + log.error("%s called before language server started", self.request_name) + raise SolidLSPException("Language Server not started") + + @abstractmethod + def send_request(self) -> object | None: + pass + + def map_exception(self, error: Exception) -> Exception | None: + if isinstance(error, LSPError) and getattr(error, "code", None) == -32603: + return RuntimeError( + f"LSP internal error (-32603) when requesting {self.request_name} for " + f"{self.relative_file_path}:{self.line}:{self.column}. " + "This often occurs when requesting a symbol in a way the language server cannot resolve." + ) + return None + + @abstractmethod + def normalize_response(self, response: object | None) -> list[ls_types.Location]: + pass + + def convert_location_item(self, item: dict[str, object], *, allow_location_links: bool = False) -> ls_types.Location | None: + if LSPConstants.URI in item and LSPConstants.RANGE in item: + uri = cast(str, item[LSPConstants.URI]) + range_d = cast(ls_types.Range, item[LSPConstants.RANGE]) + elif ( + allow_location_links + and LSPConstants.TARGET_URI in item + and LSPConstants.TARGET_RANGE in item + and LSPConstants.TARGET_SELECTION_RANGE in item + ): + uri = cast(str, item[LSPConstants.TARGET_URI]) + range_d = cast(ls_types.Range, item[LSPConstants.TARGET_SELECTION_RANGE]) + else: + raise AssertionError(f"Unexpected response from Language Server: {item}") + + abs_path = PathUtils.uri_to_path(uri) + rel_path_str = PathUtils.get_relative_path(abs_path, self.language_server.repository_root_path) + + if rel_path_str is None: + log.warning( + "Found a %s in a path outside the repository, probably the LS is parsing things in installed packages or in the standardlib! " + "Path: %s. This is a bug but we currently simply skip these locations.", + self.request_name, + abs_path, + ) + return None + + if self.skip_ignored_paths and self.language_server.is_ignored_path(rel_path_str): + log.info("%s found symbol in ignored path: %s", self.request_name, rel_path_str) + return None + + return ls_types.Location(uri=uri, range=range_d, absolutePath=str(abs_path), relativePath=rel_path_str) + + def log_perf_result(self, t0: float, result: list[ls_types.Location]) -> None: + return + + class DefinitionLocationRequest(SymbolLocationRequest): + def __init__( + self, + language_server: "SolidLanguageServer", + relative_file_path: str, + line: int, + column: int, + *, + request_name: str = "request_definition", + ) -> None: + super().__init__( + language_server, + relative_file_path, + line, + column, + request_name=request_name, + ) + + def send_request(self) -> object | None: + return self.language_server._send_definition_request( + self.language_server._create_text_document_position_params(self.relative_file_path, self.line, self.column) + ) + + def normalize_response(self, response: object | None) -> list[ls_types.Location]: + if response is None: + log.warning( + "Language server returned None for %s request at %s:%s:%s", + self.request_name, + self.relative_file_path, + self.line, + self.column, + ) + return [] + + ret: list[ls_types.Location] = [] + if isinstance(response, list): + for item in response: + assert isinstance(item, dict), f"Unexpected response from Language Server (expected dict, got {type(item)}): {item}" + if location := self.convert_location_item(cast(dict[str, object], item), allow_location_links=True): + ret.append(location) + return ret + + if isinstance(response, dict): + if location := self.convert_location_item(cast(dict[str, object], response), allow_location_links=True): + ret.append(location) + return ret + + assert False, f"Unexpected response from Language Server: {response}" + def request_definition(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]: """ Raise a [textDocument/definition](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_definition) request to the Language Server @@ -857,70 +1016,60 @@ class SolidLanguageServer(ABC): :return: the list of locations where the symbol is defined """ - if not self.server_started: - log.error("request_definition called before language server started") - raise SolidLSPException("Language Server not started") + request = self.DefinitionLocationRequest(self, relative_file_path, line, column) + return request.execute() + def _send_implementation_request(self, implementation_params: ImplementationParams) -> Definition | list[LocationLink] | None: + return self.server.send.implementation(implementation_params) + + def _create_text_document_position_params(self, relative_file_path: str, line: int, column: int) -> DefinitionParams: + return cast( + DefinitionParams, + { + LSPConstants.TEXT_DOCUMENT: { + LSPConstants.URI: pathlib.Path(str(PurePath(self.repository_root_path, relative_file_path))).as_uri() + }, + LSPConstants.POSITION: { + LSPConstants.LINE: line, + LSPConstants.CHARACTER: column, + }, + }, + ) + + def _wait_for_cross_file_references_if_needed(self) -> None: if not self._has_waited_for_cross_file_references: - # Some LS require waiting for a while before they can return cross-file definitions. - # This is a workaround for such LS that don't have a reliable "finished initializing" signal. + # Some LS require waiting for a while before they can return accurate cross-file results. + # The waiting has to happen after at least one file was opened in the LS. sleep(self._get_wait_time_for_cross_file_referencing()) self._has_waited_for_cross_file_references = True - with self.open_file(relative_file_path): - # sending request to the language server and waiting for response - definition_params = cast( - DefinitionParams, - { - LSPConstants.TEXT_DOCUMENT: { - LSPConstants.URI: pathlib.Path(str(PurePath(self.repository_root_path, relative_file_path))).as_uri() - }, - LSPConstants.POSITION: { - LSPConstants.LINE: line, - LSPConstants.CHARACTER: column, - }, - }, + class ImplementationLocationRequest(DefinitionLocationRequest): + def __init__(self, language_server: "SolidLanguageServer", relative_file_path: str, line: int, column: int) -> None: + super().__init__( + language_server, + relative_file_path, + line, + column, + request_name="request_implementation", ) - response = self._send_definition_request(definition_params) - ret: list[ls_types.Location] = [] - if isinstance(response, list): - # response is either of type Location[] or LocationLink[] - for item in response: - assert isinstance(item, dict) - if LSPConstants.URI in item and LSPConstants.RANGE in item: - new_item: dict = {} - new_item.update(item) - new_item["absolutePath"] = PathUtils.uri_to_path(new_item["uri"]) - new_item["relativePath"] = PathUtils.get_relative_path(new_item["absolutePath"], self.repository_root_path) - ret.append(ls_types.Location(**new_item)) # type: ignore - elif LSPConstants.TARGET_URI in item and LSPConstants.TARGET_RANGE in item and LSPConstants.TARGET_SELECTION_RANGE in item: - new_item: dict = {} # type: ignore - new_item["uri"] = item[LSPConstants.TARGET_URI] # type: ignore - new_item["absolutePath"] = PathUtils.uri_to_path(new_item["uri"]) - new_item["relativePath"] = PathUtils.get_relative_path(new_item["absolutePath"], self.repository_root_path) - new_item["range"] = item[LSPConstants.TARGET_SELECTION_RANGE] # type: ignore - ret.append(ls_types.Location(**new_item)) # type: ignore - else: - assert False, f"Unexpected response from Language Server: {item}" - elif isinstance(response, dict): - # response is of type Location - assert LSPConstants.URI in response - assert LSPConstants.RANGE in response + def send_request(self) -> object | None: + return self.language_server._send_implementation_request( + self.language_server._create_text_document_position_params(self.relative_file_path, self.line, self.column), + ) - new_item: dict = {} # type: ignore - new_item.update(response) - new_item["absolutePath"] = PathUtils.uri_to_path(new_item["uri"]) - new_item["relativePath"] = PathUtils.get_relative_path(new_item["absolutePath"], self.repository_root_path) - ret.append(ls_types.Location(**new_item)) # type: ignore - elif response is None: - # Some language servers return None when they cannot find a definition - # This is expected for certain symbol types like generics or types with incomplete information - log.warning(f"Language server returned None for definition request at {relative_file_path}:{line}:{column}") - else: - assert False, f"Unexpected response from Language Server: {response}" + def request_implementation(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]: + """ + Raise a [textDocument/implementation](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_implementation) + request to the Language Server for the symbol at the given line and column in the given file. - return ret + :param relative_file_path: The relative path of the file that has the symbol for which implementations should be looked up + :param line: The line number of the symbol + :param column: The column number of the symbol + :return: the list of locations where the symbol is implemented + """ + request = self.ImplementationLocationRequest(self, relative_file_path, line, column) + return request.execute() # Some LS cause problems with this, so the call is isolated from the rest to allow overriding in subclasses def _send_references_request(self, relative_file_path: str, line: int, column: int) -> list[lsp_types.Location] | None: @@ -932,6 +1081,46 @@ class SolidLanguageServer(ABC): } ) + class ReferencesLocationRequest(SymbolLocationRequest): + def __init__(self, language_server: "SolidLanguageServer", relative_file_path: str, line: int, column: int) -> None: + super().__init__( + language_server, + relative_file_path, + line, + column, + request_name="request_references", + ) + + def send_request(self) -> object | None: + return self.language_server._send_references_request(self.relative_file_path, line=self.line, column=self.column) + + def normalize_response(self, response: object | None) -> list[ls_types.Location]: + if response is None: + return [] + + assert isinstance(response, list), f"Unexpected response from Language Server (expected list, got {type(response)}): {response}" + ret: list[ls_types.Location] = [] + for item in response: + assert isinstance(item, dict), f"Unexpected response from Language Server (expected dict, got {type(item)}): {item}" + if location := self.convert_location_item(cast(dict[str, object], item)): + ret.append(location) + return ret + + def log_perf_result(self, t0: float, result: list[ls_types.Location]) -> None: + elapsed_ms = (perf_counter() - t0) * 1000 + if not result: + log.debug("perf: request_references path=%s elapsed_ms=%.2f count=0", self.relative_file_path, elapsed_ms) + return + + unique_files = len({r["relativePath"] for r in result}) + log.debug( + "perf: request_references path=%s elapsed_ms=%.2f count=%d unique_files=%d", + self.relative_file_path, + elapsed_ms, + len(result), + unique_files, + ) + def request_references(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]: """ Raise a [textDocument/references](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_references) request to the Language Server @@ -944,111 +1133,8 @@ class SolidLanguageServer(ABC): :return: A list of locations where the symbol is referenced (excluding ignored directories) """ - if not self.server_started: - log.error("request_references called before Language Server started") - raise SolidLSPException("Language Server not started") - - with self.open_file(relative_file_path): - if not self._has_waited_for_cross_file_references: - # Some LS require waiting for a while before they can return cross-file references. - # This is a workaround for such LS that don't have a reliable "finished initializing" signal. - # The waiting has to happen after at least one file was opened in the ls - sleep(self._get_wait_time_for_cross_file_referencing()) - self._has_waited_for_cross_file_references = True - t0 = perf_counter() if _debug_enabled else 0.0 - try: - response = self._send_references_request(relative_file_path, line=line, column=column) - except Exception as e: - # Catch LSP internal error (-32603) and raise a more informative exception - if isinstance(e, LSPError) and getattr(e, "code", None) == -32603: - raise RuntimeError( - f"LSP internal error (-32603) when requesting references for {relative_file_path}:{line}:{column}. " - "This often occurs when requesting references for a symbol not referenced in the expected way. " - ) from e - raise - if response is None: - if _debug_enabled: - elapsed_ms = (perf_counter() - t0) * 1000 - log.debug("perf: request_references path=%s elapsed_ms=%.2f count=0", relative_file_path, elapsed_ms) - return [] - - ret: list[ls_types.Location] = [] - assert isinstance(response, list), f"Unexpected response from Language Server (expected list, got {type(response)}): {response}" - for item in response: - assert isinstance(item, dict), f"Unexpected response from Language Server (expected dict, got {type(item)}): {item}" - assert LSPConstants.URI in item - assert LSPConstants.RANGE in item - - abs_path = PathUtils.uri_to_path(item[LSPConstants.URI]) # type: ignore - if not Path(abs_path).is_relative_to(self.repository_root_path): - log.warning( - "Found a reference in a path outside the repository, probably the LS is parsing things in installed packages or in the standardlib! " - f"Path: {abs_path}. This is a bug but we currently simply skip these references." - ) - continue - - rel_path = Path(abs_path).relative_to(self.repository_root_path) - if self.is_ignored_path(str(rel_path)): - log.debug("Ignoring reference in %s since it should be ignored", rel_path) - continue - - new_item: dict = {} - new_item.update(item) - new_item["absolutePath"] = str(abs_path) - new_item["relativePath"] = str(rel_path) - ret.append(ls_types.Location(**new_item)) # type: ignore - - if _debug_enabled: - elapsed_ms = (perf_counter() - t0) * 1000 - unique_files = len({r["relativePath"] for r in ret}) - log.debug( - "perf: request_references path=%s elapsed_ms=%.2f count=%d unique_files=%d", - relative_file_path, - elapsed_ms, - len(ret), - unique_files, - ) - - return ret - - def request_text_document_diagnostics(self, relative_file_path: str) -> list[ls_types.Diagnostic]: - """ - Raise a [textDocument/diagnostic](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_diagnostic) request to the Language Server - to find diagnostics for the given file. Wait for the response and return the result. - - :param relative_file_path: The relative path of the file to retrieve diagnostics for - - :return: A list of diagnostics for the file - """ - if not self.server_started: - log.error("request_text_document_diagnostics called before Language Server started") - raise SolidLSPException("Language Server not started") - - with self.open_file(relative_file_path): - response = self.server.send.text_document_diagnostic( - { - LSPConstants.TEXT_DOCUMENT: { # type: ignore - LSPConstants.URI: pathlib.Path(str(PurePath(self.repository_root_path, relative_file_path))).as_uri() - } - } - ) - - if response is None: - return [] # type: ignore - - assert isinstance(response, dict), f"Unexpected response from Language Server (expected list, got {type(response)}): {response}" - ret: list[ls_types.Diagnostic] = [] - for item in response["items"]: # type: ignore - new_item: ls_types.Diagnostic = { - "uri": pathlib.Path(str(PurePath(self.repository_root_path, relative_file_path))).as_uri(), - "severity": item["severity"], - "message": item["message"], - "range": item["range"], - "code": item["code"], # type: ignore - } - ret.append(ls_types.Diagnostic(**new_item)) - - return ret + request = self.ReferencesLocationRequest(self, relative_file_path, line, column) + return request.execute() def retrieve_full_file_content(self, file_path: str) -> str: """ @@ -1945,6 +2031,153 @@ class SolidLanguageServer(ABC): """ return definitions[0] + def _get_document_symbols_with_locations(self, relative_file_path: str) -> list[ls_types.UnifiedSymbolInformation]: + absolute_file_path = str(PurePath(self.repository_root_path, relative_file_path)) + document_symbols = self.request_document_symbols(relative_file_path) + symbols = list(document_symbols.iter_symbols()) + + # Make SymbolInformation and DocumentSymbol shapes consistent by ensuring every + # symbol exposes a normalized location/range in the current workspace. + for symbol in symbols: + location = symbol["location"] + location["absolutePath"] = absolute_file_path + location["relativePath"] = relative_file_path + location["uri"] = Path(absolute_file_path).as_uri() + return symbols + + @staticmethod + def _position_matches_range(range_d: ls_types.Range, line: int, column: int | None = None) -> bool: + start = range_d["start"] + end = range_d["end"] + if not (start["line"] <= line <= end["line"]): + return False + if column is None: + return True + if line == start["line"] and column < start["character"]: + return False + if line == end["line"] and column > end["character"]: + return False + return True + + @staticmethod + def _symbol_match_sort_key(symbol: ls_types.UnifiedSymbolInformation, match_priority: int) -> tuple[int, int, int, int, int]: + location = symbol["location"] + symbol_range = location["range"] + start = symbol_range["start"] + end = symbol_range["end"] + line_span = end["line"] - start["line"] + character_span = end["character"] - start["character"] if line_span == 0 else end["character"] + return match_priority, line_span, character_span, start["line"], start["character"] + + def _request_symbol_at_location( + self, + relative_file_path: str, + line: int, + column: int, + include_body: bool = False, + body_factory: SymbolBodyFactory | None = None, + ) -> ls_types.UnifiedSymbolInformation | None: + candidates: list[tuple[tuple[int, int, int, int, int], ls_types.UnifiedSymbolInformation]] = [] + for symbol in self._get_document_symbols_with_locations(relative_file_path): + location = symbol["location"] + symbol_range = location["range"] + selection_range = symbol.get("selectionRange") or symbol_range + + match_priority: int | None = None + if self._position_matches_range(selection_range, line, column): + match_priority = 0 + elif self._position_matches_range(symbol_range, line, column): + match_priority = 1 + else: + selection_start = selection_range["start"] + symbol_start = symbol_range["start"] + if (selection_start["line"], selection_start["character"]) == (line, column): + match_priority = 2 + elif (symbol_start["line"], symbol_start["character"]) == (line, column): + match_priority = 3 + elif selection_start["line"] == line and column <= selection_start["character"]: + match_priority = 4 + elif symbol_start["line"] == line and column <= symbol_start["character"]: + match_priority = 5 + + if match_priority is None: + continue + candidates.append((self._symbol_match_sort_key(symbol, match_priority), symbol)) + + if not candidates: + return None + + candidates.sort(key=lambda item: item[0]) + best_symbol = candidates[0][1] + if include_body: + best_symbol["body"] = self.create_symbol_body(best_symbol, factory=body_factory) + return best_symbol + + @staticmethod + def _iter_symbol_descendants(symbol: ls_types.UnifiedSymbolInformation) -> Iterator[ls_types.UnifiedSymbolInformation]: + """Yield descendant symbols in depth-first order.""" + for child in symbol.get("children", []): + yield child + yield from SolidLanguageServer._iter_symbol_descendants(child) + + def _refine_implementing_symbol( + self, + target_symbol: ls_types.UnifiedSymbolInformation | None, + implementing_symbol: ls_types.UnifiedSymbolInformation, + include_body: bool = False, + ) -> ls_types.UnifiedSymbolInformation: + """Resolve member-level implementation symbols when the LS returns a containing type.""" + if target_symbol is None: + return implementing_symbol + + target_kind = target_symbol["kind"] + if target_kind not in (ls_types.SymbolKind.Method, ls_types.SymbolKind.Function): + return implementing_symbol + + if implementing_symbol["kind"] == target_kind and implementing_symbol.get("name") == target_symbol.get("name"): + return implementing_symbol + + candidate_descendants: list[ls_types.UnifiedSymbolInformation] = [] + for descendant in self._iter_symbol_descendants(implementing_symbol): + if descendant.get("name") != target_symbol.get("name"): + continue + if descendant["kind"] != target_kind: + continue + candidate_descendants.append(descendant) + + if not candidate_descendants: + return implementing_symbol + + refined_symbol = min( + candidate_descendants, + key=lambda symbol: self._symbol_match_sort_key(symbol, match_priority=0), + ) + if include_body: + refined_symbol["body"] = self.create_symbol_body(refined_symbol) + return refined_symbol + + def request_symbol_at_location( + self, + relative_file_path: str, + line: int, + column: int, + include_body: bool = False, + ) -> ls_types.UnifiedSymbolInformation | None: + """ + Finds the symbol at the given position, preferring exact identifier matches and otherwise + falling back to the innermost symbol whose body contains the position. + + :param relative_file_path: The relative path to the file. + :param line: The 0-indexed line number. + :param column: The 0-indexed column number. + :param include_body: whether to include the body of the symbol in the result. + :return: The symbol at the given location, or None if no symbol could be resolved. + """ + if not self.server_started: + log.error("request_symbol_at_location called before language server started") + raise SolidLSPException("Language Server not started") + return self._request_symbol_at_location(relative_file_path, line, column, include_body=include_body) + def request_defining_symbol( self, relative_file_path: str, @@ -1976,14 +2209,79 @@ class SolidLanguageServer(ABC): # Select the preferred definition (subclasses can override _get_preferred_definition) definition = self._get_preferred_definition(definitions) def_path = definition["relativePath"] - assert def_path is not None + if def_path is None: + return None def_line = definition["range"]["start"]["line"] def_col = definition["range"]["start"]["character"] - # Find the symbol at or containing this location - defining_symbol = self.request_containing_symbol(def_path, def_line, def_col, strict=False, include_body=include_body) + return self._request_symbol_at_location( + def_path, + def_line, + def_col, + include_body=include_body, + ) - return defining_symbol + def request_implementing_symbols( + self, + relative_file_path: str, + line: int, + column: int, + include_body: bool = False, + ) -> list[ls_types.UnifiedSymbolInformation]: + """ + Finds the symbols that implement the symbol at the given location. + + This method first finds implementation locations for the symbol at the given position, + then retrieves the full symbol information for each implementation and de-duplicates + results that map to the same containing symbol. + + :param relative_file_path: The relative path to the file. + :param line: The 0-indexed line number. + :param column: The 0-indexed column number. + :param include_body: whether to include the body of the symbols in the result. + :return: The symbol information for each implementation. + """ + if not self.server_started: + log.error("request_implementing_symbols called before language server started") + raise SolidLSPException("Language Server not started") + + target_symbol = self._request_symbol_at_location(relative_file_path, line, column, include_body=False) + implementation_locations = self.request_implementation(relative_file_path, line, column) + if not implementation_locations: + return [] + + result: list[ls_types.UnifiedSymbolInformation] = [] + seen_keys: set[tuple[str, int, int, int]] = set() + for implementation in implementation_locations: + implementation_path = implementation["relativePath"] + assert implementation_path is not None + implementation_line = implementation["range"]["start"]["line"] + implementation_col = implementation["range"]["start"]["character"] + implementing_symbol = self._request_symbol_at_location( + implementation_path, + implementation_line, + implementation_col, + include_body=include_body, + body_factory=None, + ) + if implementing_symbol is None: + continue + implementing_symbol = self._refine_implementing_symbol(target_symbol, implementing_symbol, include_body=include_body) + if "location" not in implementing_symbol: + continue + symbol_location = implementing_symbol["location"] + symbol_key = ( + cast(str, symbol_location["relativePath"]), + symbol_location["range"]["start"]["line"], + symbol_location["range"]["start"]["character"], + implementing_symbol["kind"], + ) + if symbol_key in seen_keys: + continue + seen_keys.add(symbol_key) + result.append(implementing_symbol) + + return result def _document_symbols_cache_fingerprint(self) -> Hashable | None: """ diff --git a/test/solidlsp/dart/test_dart_basic.py b/test/solidlsp/dart/test_dart_basic.py index 450f99c4..cdd98a97 100644 --- a/test/solidlsp/dart/test_dart_basic.py +++ b/test/solidlsp/dart/test_dart_basic.py @@ -3,6 +3,7 @@ from pathlib import Path import pytest +from serena.util.text_utils import find_text_coordinates from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind @@ -243,8 +244,12 @@ class TestDartLanguageServer: def test_request_defining_symbol_variable(self, language_server: SolidLanguageServer) -> None: """Test request_defining_symbol for a variable usage.""" file_path = os.path.join("lib", "main.dart") - # Line 14 contains 'final result = a + b;' - test position on 'result' - defining_symbol = language_server.request_defining_symbol(file_path, 13, 10) + + # Find coordinates of 'final result = a + b;' - test position on 'result' + with language_server.open_file(file_path, open_in_ls=False) as f: + pos = find_text_coordinates(f.contents, r"final (result) = a \+ b;") + + defining_symbol = language_server.request_defining_symbol(file_path, pos.line, pos.col) # The defining symbol might be the variable itself or the containing method # This is acceptable behavior - different language servers handle this differently diff --git a/test/solidlsp/ruby/test_ruby_symbol_retrieval.py b/test/solidlsp/ruby/test_ruby_symbol_retrieval.py index 5d969094..ef0c50fe 100644 --- a/test/solidlsp/ruby/test_ruby_symbol_retrieval.py +++ b/test/solidlsp/ruby/test_ruby_symbol_retrieval.py @@ -12,6 +12,7 @@ import os import pytest +from serena.util.text_utils import find_text_coordinates from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind @@ -583,15 +584,17 @@ class TestRubyLanguageServerSymbols: """Test request_defining_symbol for a method call.""" # Test finding the definition of a method being called file_path = os.path.join("examples", "user_management.rb") - # Position at a method call like create_user - defining_symbol = language_server.request_defining_symbol(file_path, 30, 15) - # Verify that we can find method definitions - if defining_symbol is not None: - assert "name" in defining_symbol - assert "kind" in defining_symbol - # Should be a method or constructor - assert defining_symbol.get("kind") in [SymbolKind.Method.value, SymbolKind.Constructor.value, SymbolKind.Function.value] + # Position at a method call like create_user + with language_server.open_file(file_path, open_in_ls=False) as fb: + pos = find_text_coordinates(fb.contents, r"user = @service\.(create_user)") + + # Verify that we can find the method definition + defining_symbol = language_server.request_defining_symbol(file_path, pos.line, pos.col) + assert "name" in defining_symbol + assert "kind" in defining_symbol + assert defining_symbol.get("name") == "create_user" + assert defining_symbol.get("kind") == SymbolKind.Method.value @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) def test_request_defining_symbol_nested_function(self, language_server: SolidLanguageServer) -> None: