Skip to content

Commit d650f30

Browse files
authored
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
1 parent 9d64931 commit d650f30

3 files changed

Lines changed: 114 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,12 @@ Status of the `main` branch. Changes prior to the next official version change w
1010
* Dependencies:
1111
- `pywebview`: Switch back to official release (new version 6.2) #1253
1212

13-
* Language Servers:
13+
14+
* Language Servers:
15+
- Fix: clangd capability checks now tolerate valid initialize response shape differences and invalidate cached C++ document symbols when clangd/compile commands context changes #1359
1416
- Fix: `rename_symbol` for Vue files now correctly propagates edits to the TypeScript server, enabling cross-file renames in `.vue` files
1517

18+
1619
# v1.1.2 (2026-04-14)
1720

1821
* General:

src/solidlsp/language_servers/clangd_language_server.py

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import hashlib
12
import json
23
import logging
34
import os
45
import pathlib
56
import threading
7+
from collections.abc import Hashable
68
from typing import Any, cast
79

810
from overrides import override
@@ -42,6 +44,30 @@ def __init__(self, config: LanguageServerConfig, repository_root_path: str, soli
4244
self.initialize_searcher_command_available = threading.Event()
4345
self.resolve_main_method_available = threading.Event()
4446

47+
@override
48+
def _document_symbols_cache_fingerprint(self) -> Hashable:
49+
cache_format_version = 1
50+
cpp_settings: dict[str, Any] = self._custom_settings or {}
51+
return (
52+
cache_format_version,
53+
cpp_settings.get("clangd_version"),
54+
cpp_settings.get("ls_path"),
55+
cpp_settings.get("compile_commands_dir"),
56+
self._compile_commands_fingerprint(),
57+
)
58+
59+
def _compile_commands_fingerprint(self) -> str | None:
60+
compile_db_path = os.path.join(self.repository_root_path, "compile_commands.json")
61+
if not os.path.exists(compile_db_path):
62+
return None
63+
64+
try:
65+
with open(compile_db_path, "rb") as f:
66+
return hashlib.md5(f.read()).hexdigest()
67+
except OSError as e:
68+
log.warning(f"Failed to fingerprint compile_commands.json: {e}")
69+
return None
70+
4571
@override
4672
def is_ignored_dirname(self, dirname: str) -> bool:
4773
ignored_dirs = [
@@ -321,12 +347,19 @@ def window_log_message(msg: dict) -> None:
321347

322348
log.info("Sending initialize request from LSP client to LSP server and awaiting response")
323349
init_response = self.server.send.initialize(initialize_params)
324-
assert init_response["capabilities"]["textDocumentSync"]["change"] == 2 # type: ignore
325-
assert "completionProvider" in init_response["capabilities"]
326-
assert init_response["capabilities"]["completionProvider"] == {
327-
"triggerCharacters": [".", "<", ">", ":", '"', "/", "*"],
328-
"resolveProvider": False,
329-
}
350+
capabilities = init_response["capabilities"]
351+
352+
text_document_sync = capabilities["textDocumentSync"]
353+
if isinstance(text_document_sync, int):
354+
assert text_document_sync == 2 # type: ignore
355+
else:
356+
assert text_document_sync["change"] == 2 # type: ignore
357+
358+
assert "completionProvider" in capabilities
359+
completion_provider = capabilities["completionProvider"]
360+
trigger_characters = set(completion_provider["triggerCharacters"])
361+
assert {".", "<", ">", ":", '"', "/"}.issubset(trigger_characters)
362+
assert completion_provider["resolveProvider"] is False
330363

331364
self.server.notify.initialized({})
332365
# set ready flag, clangd sends no meaningful notification when ready

test/solidlsp/cpp/test_cpp_basic.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@
99
import os
1010
import pathlib
1111
import shutil
12+
from pathlib import Path
1213

1314
import pytest
1415

1516
from solidlsp import SolidLanguageServer
1617
from solidlsp.ls_config import Language
1718
from solidlsp.ls_utils import SymbolUtils
19+
from test.conftest import get_repo_path, start_ls_context
1820
from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols
1921

2022

@@ -156,3 +158,72 @@ def test_bare_symbol_names(self, language_server) -> None:
156158
f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}",
157159
pytrace=False,
158160
)
161+
162+
163+
@pytest.mark.cpp
164+
class TestCppDocumentSymbolCache:
165+
def _copy_cpp_fixture(self, tmp_path: Path) -> Path:
166+
fixture_path = get_repo_path(Language.CPP)
167+
target_path = tmp_path / "test_repo"
168+
shutil.copytree(fixture_path, target_path)
169+
return target_path
170+
171+
def test_cache_invalidates_when_clangd_context_changes(self, tmp_path: Path) -> None:
172+
repo_path = self._copy_cpp_fixture(tmp_path)
173+
ls_settings_alt = {
174+
Language.CPP: {
175+
"compile_commands_dir": ".serena-alt",
176+
}
177+
}
178+
179+
main_cpp = os.path.join("a.cpp")
180+
181+
def _assert_caches_loaded_and_clean(ls: SolidLanguageServer) -> None:
182+
assert ls._raw_document_symbols_cache, "Expected raw document-symbol cache to load from disk"
183+
assert ls._document_symbols_cache, "Expected document-symbol cache to load from disk"
184+
assert not ls._raw_document_symbols_cache_is_modified
185+
assert not ls._document_symbols_cache_is_modified
186+
187+
def _assert_caches_empty(ls: SolidLanguageServer) -> None:
188+
assert ls._raw_document_symbols_cache == {}
189+
assert ls._document_symbols_cache == {}
190+
191+
def _assert_caches_modified(ls: SolidLanguageServer) -> None:
192+
assert ls._raw_document_symbols_cache_is_modified
193+
assert ls._document_symbols_cache_is_modified
194+
195+
with start_ls_context(Language.CPP, repo_path=str(repo_path), solidlsp_dir=tmp_path) as ls_default:
196+
_ = ls_default.request_document_symbols(main_cpp)
197+
198+
default_raw_cache_version = ls_default._raw_document_symbols_cache_version()
199+
default_doc_cache_version = ls_default._document_symbols_cache_version()
200+
201+
ls_default.save_cache()
202+
cache_dir = ls_default.cache_dir
203+
cache_files = [p for p in cache_dir.rglob("*") if p.is_file()]
204+
assert cache_files, f"Expected SolidLSP to create cache artifacts under {cache_dir}"
205+
206+
with start_ls_context(Language.CPP, repo_path=str(repo_path), solidlsp_dir=tmp_path) as ls_default_again:
207+
assert ls_default_again.cache_dir == cache_dir
208+
_assert_caches_loaded_and_clean(ls_default_again)
209+
_ = ls_default_again.request_document_symbols(main_cpp)
210+
assert not ls_default_again._raw_document_symbols_cache_is_modified
211+
assert not ls_default_again._document_symbols_cache_is_modified
212+
213+
with start_ls_context(
214+
Language.CPP,
215+
repo_path=str(repo_path),
216+
ls_specific_settings=ls_settings_alt,
217+
solidlsp_dir=tmp_path,
218+
) as ls_alt:
219+
assert ls_alt.cache_dir == cache_dir
220+
alt_raw_cache_version = ls_alt._raw_document_symbols_cache_version()
221+
alt_doc_cache_version = ls_alt._document_symbols_cache_version()
222+
223+
assert alt_raw_cache_version != default_raw_cache_version
224+
assert alt_doc_cache_version != default_doc_cache_version
225+
226+
_assert_caches_empty(ls_alt)
227+
228+
_ = ls_alt.request_document_symbols(main_cpp)
229+
_assert_caches_modified(ls_alt)

0 commit comments

Comments
 (0)