diff --git a/src/solidlsp/language_servers/clangd_language_server.py b/src/solidlsp/language_servers/clangd_language_server.py index 0dc5ed9c1..ad1f0ad23 100644 --- a/src/solidlsp/language_servers/clangd_language_server.py +++ b/src/solidlsp/language_servers/clangd_language_server.py @@ -32,6 +32,8 @@ class ClangdLanguageServer(SolidLanguageServer): ``compile_commands.json`` if needed. - clangd_version: Override the pinned Clangd version downloaded by Serena (default: the bundled Serena version). + - prime_files: Translation units to keep open after startup in order to + provide clangd with stable TU context for header queries. """ def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): @@ -368,3 +370,32 @@ def window_log_message(msg: dict) -> None: # wait for server to be ready self.server_ready.wait() + self._prime_translation_units() + + def _prime_translation_units(self) -> None: + prime_files = self._custom_settings.get("prime_files", []) + if not prime_files: + return + if not isinstance(prime_files, list): + raise TypeError("ls_specific_settings['cpp']['prime_files'] must be a list of relative file paths") + + for relative_path in prime_files: + if not isinstance(relative_path, str): + raise TypeError("Each entry in ls_specific_settings['cpp']['prime_files'] must be a string") + + absolute_path = pathlib.Path(self.repository_root_path, relative_path) + if not absolute_path.is_file(): + log.warning("Skipping clangd prime file %s because it does not exist", relative_path) + continue + if self.is_ignored_path(relative_path): + log.warning("Skipping clangd prime file %s because it is ignored", relative_path) + continue + + file_buffer = self.open_file_persistently(relative_path) + log.info("Opened clangd prime file %s persistently", relative_path) + + try: + self.request_document_symbols(relative_path, file_buffer=file_buffer) + log.info("Warmed clangd prime file %s via document symbols request", relative_path) + except Exception as e: + log.warning("Failed to warm clangd prime file %s: %s", relative_path, e) diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 6ae69a39d..1d7b92537 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -510,6 +510,7 @@ def __init__( self.language_id = language_id self.open_file_buffers: dict[str, LSPFileBuffer] = {} + self._persistently_open_file_uris: set[str] = set() self.language = Language(language_id) # initialise symbol caches @@ -660,6 +661,14 @@ def _shutdown(self, timeout: float = 5.0) -> None: A robust shutdown process designed to terminate cleanly on all platforms, including Windows, by explicitly closing all I/O pipes. """ + for uri in list(self._persistently_open_file_uris): + self.close_file_persistently(uri=uri) + + for uri, file_buffer in list(self.open_file_buffers.items()): + if file_buffer.ref_count == 0: + file_buffer.close() + del self.open_file_buffers[uri] + if not self.server.is_running(): log.debug("Server process not running, skipping shutdown.") return @@ -736,6 +745,64 @@ def _get_language_id_for_file(self, relative_file_path: str) -> str: """ return self.language_id + def open_file_persistently(self, relative_file_path: str, open_in_ls: bool = True) -> LSPFileBuffer: + """ + Open a file in the language server and keep it open across tool calls. + + The caller must explicitly close the file later via ``close_file_persistently`` or + rely on server shutdown to clean it up. + + :param relative_file_path: The relative path of the file to keep open. + :param open_in_ls: whether to send the didOpen notification immediately. + :return: the managed file buffer + """ + if not self.server_started: + log.error("open_file_persistently called before Language Server started") + raise SolidLSPException("Language Server not started") + + absolute_file_path = Path(self.repository_root_path, relative_file_path) + uri = absolute_file_path.as_uri() + + if uri in self.open_file_buffers: + file_buffer = self.open_file_buffers[uri] + if open_in_ls: + file_buffer.ensure_open_in_ls() + else: + version = 0 + language_id = self._get_language_id_for_file(relative_file_path) + file_buffer = LSPFileBuffer( + abs_path=absolute_file_path, + uri=uri, + encoding=self._encoding, + version=version, + language_id=language_id, + ref_count=0, + language_server=self, + open_in_ls=open_in_ls, + ) + self.open_file_buffers[uri] = file_buffer + + self._persistently_open_file_uris.add(uri) + return file_buffer + + def close_file_persistently(self, relative_file_path: str | None = None, uri: str | None = None) -> None: + """ + Close a file that was previously opened persistently. + + :param relative_file_path: The relative path of the file to close. + :param uri: The file URI to close. Either this or ``relative_file_path`` must be provided. + """ + if uri is None: + if relative_file_path is None: + raise ValueError("Either relative_file_path or uri must be provided") + uri = Path(self.repository_root_path, relative_file_path).as_uri() + + self._persistently_open_file_uris.discard(uri) + file_buffer = self.open_file_buffers.get(uri) + if file_buffer is not None and file_buffer.ref_count == 0: + file_buffer.close() + del self.open_file_buffers[uri] + @contextmanager def open_file(self, relative_file_path: str, open_in_ls: bool = True) -> Iterator[LSPFileBuffer]: """ @@ -756,7 +823,7 @@ def open_file(self, relative_file_path: str, open_in_ls: bool = True) -> Iterato if uri in self.open_file_buffers: fb = self.open_file_buffers[uri] assert fb.uri == uri - assert fb.ref_count >= 1 + assert fb.ref_count >= 0 fb.ref_count += 1 if open_in_ls: @@ -781,8 +848,9 @@ def open_file(self, relative_file_path: str, open_in_ls: bool = True) -> Iterato fb.ref_count -= 1 if self.open_file_buffers[uri].ref_count == 0: - self.open_file_buffers[uri].close() - del self.open_file_buffers[uri] + if uri not in self._persistently_open_file_uris: + self.open_file_buffers[uri].close() + del self.open_file_buffers[uri] @contextmanager def _open_file_context( diff --git a/test/solidlsp/test_ls_common.py b/test/solidlsp/test_ls_common.py index e5e009158..b5053aada 100644 --- a/test/solidlsp/test_ls_common.py +++ b/test/solidlsp/test_ls_common.py @@ -1,4 +1,5 @@ import os +import pathlib import pytest @@ -41,3 +42,23 @@ def test_open_file_cache_invalidate(self, language_server: SolidLanguageServer) finally: os.remove(file_path) + + @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) + def test_open_file_persistently(self, language_server: SolidLanguageServer) -> None: + """ + Tests that a persistently opened file remains available across normal open_file scopes. + """ + file_path = os.path.join("test_repo", "nested.py") + uri = pathlib.Path(language_server.repository_root_path, file_path).as_uri() + + file_buffer = language_server.open_file_persistently(file_path) + assert file_buffer.uri == uri + assert uri in language_server.open_file_buffers + + with language_server.open_file(file_path) as scoped_buffer: + assert scoped_buffer.uri == uri + + assert uri in language_server.open_file_buffers + + language_server.close_file_persistently(file_path) + assert uri not in language_server.open_file_buffers