From d650f30c7ed93fba475cfbdff424eb4ba639b1e0 Mon Sep 17 00:00:00 2001 From: mesa3 <43036352+mesa3@users.noreply.github.com> Date: Thu, 16 Apr 2026 18:19:29 +0800 Subject: [PATCH] Fix clangd cache invalidation and capability checks (#1359) Summary relax clangd initialize capability checks to tolerate valid response shape differences add a clangd-specific document-symbol cache fingerprint so stale C++ symbol caches are invalidated when backend/config state changes add a regression test covering C++ document-symbol cache invalidation Problem For C++ projects, Serena's symbol tools could keep returning malformed cached document symbols even after clangd / compile commands / backend state had been repaired. The cache key was effectively stable as long as file contents did not change, so previously bad symbol trees could survive later LS recovery. In addition, clangd capability checks were too strict and assumed a single exact initialize-response shape. What this changes accepts both integer and object-style textDocumentSync responses validates completionProvider more conservatively by checking required trigger characters instead of exact equality fingerprints clangd document-symbol caches using: clangd_version ls_path compile_commands_dir compile_commands.json content hash adds a C++ regression test similar to the existing Go cache invalidation coverage --- CHANGELOG.md | 5 +- .../clangd_language_server.py | 45 ++++++++++-- test/solidlsp/cpp/test_cpp_basic.py | 71 +++++++++++++++++++ 3 files changed, 114 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fb21277..2cf145fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,12 @@ Status of the `main` branch. Changes prior to the next official version change w * Dependencies: - `pywebview`: Switch back to official release (new version 6.2) #1253 -* Language Servers: + +* Language Servers: + - Fix: clangd capability checks now tolerate valid initialize response shape differences and invalidate cached C++ document symbols when clangd/compile commands context changes #1359 - Fix: `rename_symbol` for Vue files now correctly propagates edits to the TypeScript server, enabling cross-file renames in `.vue` files + # v1.1.2 (2026-04-14) * General: diff --git a/src/solidlsp/language_servers/clangd_language_server.py b/src/solidlsp/language_servers/clangd_language_server.py index 0210e7d5..0dc5ed9c 100644 --- a/src/solidlsp/language_servers/clangd_language_server.py +++ b/src/solidlsp/language_servers/clangd_language_server.py @@ -1,8 +1,10 @@ +import hashlib import json import logging import os import pathlib import threading +from collections.abc import Hashable from typing import Any, cast from overrides import override @@ -42,6 +44,30 @@ class ClangdLanguageServer(SolidLanguageServer): self.initialize_searcher_command_available = threading.Event() self.resolve_main_method_available = threading.Event() + @override + def _document_symbols_cache_fingerprint(self) -> Hashable: + cache_format_version = 1 + cpp_settings: dict[str, Any] = self._custom_settings or {} + return ( + cache_format_version, + cpp_settings.get("clangd_version"), + cpp_settings.get("ls_path"), + cpp_settings.get("compile_commands_dir"), + self._compile_commands_fingerprint(), + ) + + def _compile_commands_fingerprint(self) -> str | None: + compile_db_path = os.path.join(self.repository_root_path, "compile_commands.json") + if not os.path.exists(compile_db_path): + return None + + try: + with open(compile_db_path, "rb") as f: + return hashlib.md5(f.read()).hexdigest() + except OSError as e: + log.warning(f"Failed to fingerprint compile_commands.json: {e}") + return None + @override def is_ignored_dirname(self, dirname: str) -> bool: ignored_dirs = [ @@ -321,12 +347,19 @@ class ClangdLanguageServer(SolidLanguageServer): log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) - assert init_response["capabilities"]["textDocumentSync"]["change"] == 2 # type: ignore - assert "completionProvider" in init_response["capabilities"] - assert init_response["capabilities"]["completionProvider"] == { - "triggerCharacters": [".", "<", ">", ":", '"', "/", "*"], - "resolveProvider": False, - } + capabilities = init_response["capabilities"] + + text_document_sync = capabilities["textDocumentSync"] + if isinstance(text_document_sync, int): + assert text_document_sync == 2 # type: ignore + else: + assert text_document_sync["change"] == 2 # type: ignore + + assert "completionProvider" in capabilities + completion_provider = capabilities["completionProvider"] + trigger_characters = set(completion_provider["triggerCharacters"]) + assert {".", "<", ">", ":", '"', "/"}.issubset(trigger_characters) + assert completion_provider["resolveProvider"] is False self.server.notify.initialized({}) # set ready flag, clangd sends no meaningful notification when ready diff --git a/test/solidlsp/cpp/test_cpp_basic.py b/test/solidlsp/cpp/test_cpp_basic.py index 3ca52693..dfb18881 100644 --- a/test/solidlsp/cpp/test_cpp_basic.py +++ b/test/solidlsp/cpp/test_cpp_basic.py @@ -9,12 +9,14 @@ server is not available. import os import pathlib import shutil +from pathlib import Path import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils +from test.conftest import get_repo_path, start_ls_context from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols @@ -156,3 +158,72 @@ int use_add() { f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", pytrace=False, ) + + +@pytest.mark.cpp +class TestCppDocumentSymbolCache: + def _copy_cpp_fixture(self, tmp_path: Path) -> Path: + fixture_path = get_repo_path(Language.CPP) + target_path = tmp_path / "test_repo" + shutil.copytree(fixture_path, target_path) + return target_path + + def test_cache_invalidates_when_clangd_context_changes(self, tmp_path: Path) -> None: + repo_path = self._copy_cpp_fixture(tmp_path) + ls_settings_alt = { + Language.CPP: { + "compile_commands_dir": ".serena-alt", + } + } + + main_cpp = os.path.join("a.cpp") + + def _assert_caches_loaded_and_clean(ls: SolidLanguageServer) -> None: + assert ls._raw_document_symbols_cache, "Expected raw document-symbol cache to load from disk" + assert ls._document_symbols_cache, "Expected document-symbol cache to load from disk" + assert not ls._raw_document_symbols_cache_is_modified + assert not ls._document_symbols_cache_is_modified + + def _assert_caches_empty(ls: SolidLanguageServer) -> None: + assert ls._raw_document_symbols_cache == {} + assert ls._document_symbols_cache == {} + + def _assert_caches_modified(ls: SolidLanguageServer) -> None: + assert ls._raw_document_symbols_cache_is_modified + assert ls._document_symbols_cache_is_modified + + with start_ls_context(Language.CPP, repo_path=str(repo_path), solidlsp_dir=tmp_path) as ls_default: + _ = ls_default.request_document_symbols(main_cpp) + + default_raw_cache_version = ls_default._raw_document_symbols_cache_version() + default_doc_cache_version = ls_default._document_symbols_cache_version() + + ls_default.save_cache() + cache_dir = ls_default.cache_dir + cache_files = [p for p in cache_dir.rglob("*") if p.is_file()] + assert cache_files, f"Expected SolidLSP to create cache artifacts under {cache_dir}" + + with start_ls_context(Language.CPP, repo_path=str(repo_path), solidlsp_dir=tmp_path) as ls_default_again: + assert ls_default_again.cache_dir == cache_dir + _assert_caches_loaded_and_clean(ls_default_again) + _ = ls_default_again.request_document_symbols(main_cpp) + assert not ls_default_again._raw_document_symbols_cache_is_modified + assert not ls_default_again._document_symbols_cache_is_modified + + with start_ls_context( + Language.CPP, + repo_path=str(repo_path), + ls_specific_settings=ls_settings_alt, + solidlsp_dir=tmp_path, + ) as ls_alt: + assert ls_alt.cache_dir == cache_dir + alt_raw_cache_version = ls_alt._raw_document_symbols_cache_version() + alt_doc_cache_version = ls_alt._document_symbols_cache_version() + + assert alt_raw_cache_version != default_raw_cache_version + assert alt_doc_cache_version != default_doc_cache_version + + _assert_caches_empty(ls_alt) + + _ = ls_alt.request_document_symbols(main_cpp) + _assert_caches_modified(ls_alt)