C/C++ language server fixes (#987)

Fixes longstanding clangd issues (wrong selectionRange, failure to take compile_commands.json into account) and enhances documentation about C/C++ support
This commit is contained in:
Michael Panchenko authored and GitHub committed 2026-02-06 15:25:43 +01:00
1 parent 8288bf200d
commit e588bc7f89
8 files changed
+281 -38

No files matched your search

+4 -2
View File
@@ -121,13 +121,15 @@ jobs:
sudo apt-get install -y ccls
elif [[ "${{ runner.os }}" == "macOS" ]]; then
brew install ccls
elif [[ "${{ runner.os }}" == "Windows" ]]; then
choco install ccls -y
fi
# Windows: ccls requires building from source with MSYS2/LLVM, skipped in CI
# Verify installation
if command -v ccls &> /dev/null; then
echo "ccls installed: $(ccls --version 2>&1 | head -1)"
else
echo "ccls not available on this platform (expected on Windows)"
echo "ERROR: ccls installation failed"
exit 1
fi
- name: Setup Java (for JVM based languages)
uses: actions/setup-java@v4
@@ -33,6 +33,7 @@ Some languages require additional installations or setup steps, as noted.
* **C/C++**
Default: clangd. Optional alternate: ccls (experimental, opt-in).
For best results, provide a `compile_commands.json` at the repository root.
See the [C/C++ Setup Guide](../03-special-guides/cpp_setup) for details.
* **Clojure**
* **Dart**
* **Elixir**
+100
View File
@@ -0,0 +1,100 @@
# C/C++ Setup Guide
This guide explains how to prepare a C/C++ project so that Serena can provide reliable code intelligence via clangd or ccls language servers.
This is only necessary if you use the language server variant of Serena, for users of the Serena JetBrains plugin no setup is required
and the limitations described below do not apply.
---
## General
Serena supports two C/C++ language servers, clangd (default) and ccls.
Both have their pros and cons and require a properly configured `compile_commands.json`
for cross-file reference finding, see below for details.
Your project must have a `compile_commands.json` file at the repository root.
This file is essential for correct parsing and cross-file reference finding.
## compile_commands.json Requirements
For reliable cross-file reference finding with clangd, your `compile_commands.json` must:
1. **Include proper C++ standard flags** (e.g., `-std=c++17`)
2. **Include all necessary include paths** (`-I` flags)
---
### With clangd
Serena automatically downloads and manages clangd. Since clangd does not properly work with relative paths in `compile_commands.json`,
Serena will detect them and transform them into absolute paths automatically (writing a new `compile_commands.json` file), if needed.
#### Customizing the Compilation Database Location
By default, Serena creates the transformed compilation database at `.serena/compile_commands.json`.
You can customize this location via project settings:
```yaml
# .serena/project.yml
language_servers:
cpp:
compile_commands_dir: custom/rel/path (defaults to .serena)
```
### With ccls
ccls requires manual installation and configuration. It may perform better in some situations.
#### Installation
**Linux:**
```bash
# Ubuntu/Debian (22.04+)
sudo apt-get install ccls
# Fedora/RHEL
sudo dnf install ccls
# Arch Linux
sudo pacman -S ccls
```
**macOS:**
```bash
brew install ccls
```
**Windows:**
```bash
choco install ccls
```
#### Configuration
After installing ccls, configure Serena to use it via project settings (in `.serena/project.yml`)
by adding `cpp_ccls` to the `languages` list. Replace `cpp` with `cpp_ccls` if you already have the `cpp` entry.
ccls can handle relative paths in `compile_commands.json`, so no transformation is necessary
and no transformed `compile_commands.json` file will be created.
---
## Known Limitations
### Files Created After Server Initialization
Both clangd and ccls have a fundamental limitation:
**files created by external mechanisms after the language server starts are not automatically indexed**.
Cross-file references to newly created files will not work unless the new file is at some point opened by the language server (for example, by a symbol lookup in it), or until `compile_commands.json` is updated and
the language server is restarted.
---
## Reference
- Clangd official documentation: https://clangd.llvm.org/
- Clangd project setup: https://clangd.llvm.org/installation#project-setup
- CCLS repository: https://github.com/MaskRay/ccls
@@ -1,12 +1,8 @@
"""
Provides C/C++ specific instantiation of the LanguageServer class using ccls.
This is an alternative to clangd for large C++ codebases where ccls may perform
better for indexing and navigation. Requires ccls to be installed and available
on PATH, or configured via ls_specific_settings with key "ls_path".
For best results, ensure a compile_commands.json exists at the repository root.
Installation
------------
ccls must be installed manually as there are no prebuilt binaries available for
@@ -23,10 +19,9 @@ direct download. Install using your system package manager:
- Homebrew: ``brew install ccls``
**Windows:**
- MSYS2 (MinGW): Build from source using MSYS2 toolchain
- No native prebuilt binaries available; must build from source
- Chocolatey: ``choco install ccls``
For build-from-source instructions (required on Windows), see:
For alternative installation methods and build-from-source instructions, see:
https://github.com/MaskRay/ccls/wiki/Build
Official documentation:
@@ -51,7 +46,7 @@ from solidlsp.settings import SolidLSPSettings
log = logging.getLogger(__name__)
class CclsLanguageServer(SolidLanguageServer):
class CCLS(SolidLanguageServer):
"""
C/C++ language server implementation using ccls.
@@ -89,7 +84,7 @@ class CclsLanguageServer(SolidLanguageServer):
" Linux (Fedora/RHEL): sudo dnf install ccls\n"
" Linux (Arch): sudo pacman -S ccls\n"
" macOS (Homebrew): brew install ccls\n"
" Windows: Build from source (see wiki)\n\n"
" Windows: choco install ccls\n\n"
"For build instructions and more details, see:\n"
" https://github.com/MaskRay/ccls/wiki/Build"
)
@@ -1,14 +1,11 @@
"""
Provides C/C++ specific instantiation of the LanguageServer class. Contains various configurations and settings specific to C/C++.
"""
import json
import logging
import os
import pathlib
import threading
from typing import Any, cast
from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer
from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, ProcessLaunchInfo, SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams
from solidlsp.settings import SolidLSPSettings
@@ -35,6 +32,89 @@ class ClangdLanguageServer(SolidLanguageServer):
self.initialize_searcher_command_available = threading.Event()
self.resolve_main_method_available = threading.Event()
def _prepare_compile_commands(self) -> str | None:
"""
Prepare clangd compilation database with absolute directory paths.
Clangd requires absolute directory paths in compile_commands.json for correct
cross-file reference finding. This method reads the compile_commands.json,
converts relative directory paths to absolute paths, and writes a transformed
compilation database to the serena managed directory.
The transformed file is persisted in .serena/serena_compile_commands.json
(or a configurable directory via ls_specific_settings) and is not deleted
on cleanup. This allows clangd to use the absolute-path version without
modifying the user's original compile_commands.json.
Returns the path to the serena directory containing the transformed database,
or None if no transformation was needed.
"""
compile_db_path = os.path.join(self.repository_root_path, "compile_commands.json")
if not os.path.exists(compile_db_path):
# No compile_commands.json, nothing to do
return None
try:
with open(compile_db_path, encoding="utf-8") as f:
compile_commands = json.load(f)
if not compile_commands:
return None
# Check if any entries have relative directory paths
has_relative = False
for entry in compile_commands:
directory = entry.get("directory", "")
if directory and not os.path.isabs(directory):
has_relative = True
# Convert to absolute path
entry["directory"] = os.path.abspath(os.path.join(self.repository_root_path, directory))
if not has_relative:
# No relative paths found, no need to create transformed database
return None
# Get the target directory from ls_specific_settings, default to .serena
cpp_settings: dict[str, Any] = self._custom_settings or {}
compile_commands_rel_dir = cpp_settings.get("compile_commands_dir", ".serena")
compile_commands_dir = os.path.join(self.repository_root_path, compile_commands_rel_dir)
os.makedirs(compile_commands_dir, exist_ok=True)
# Write the transformed compile_commands.json
# clangd looks for compile_commands.json in the --compile-commands-dir
compile_commands_path = os.path.join(compile_commands_dir, "compile_commands.json")
with open(compile_commands_path, "w", encoding="utf-8") as f:
json.dump(compile_commands, f, indent=2)
# Track the directory for --compile-commands-dir
log.info(f"Created serena compilation database with absolute paths at {compile_commands_path}")
return compile_commands_dir
except (OSError, json.JSONDecodeError) as e:
log.warning(f"Failed to prepare compile_commands.json: {e}")
return None
def _create_process_launch_info(self) -> ProcessLaunchInfo:
"""
Override to add --compile-commands-dir argument if we created a serena compilation database.
"""
# First, ensure the serena compile commands database is prepared
compile_commands_dir = self._prepare_compile_commands()
# Get the default launch info from parent
launch_info = super()._create_process_launch_info()
# If we created a serena compilation database, add --compile-commands-dir to the command
if compile_commands_dir:
# Insert --compile-commands-dir after the executable path
cmd = launch_info.cmd
assert isinstance(cmd, list)
launch_info.cmd = [cmd[0], f"--compile-commands-dir={compile_commands_dir}"] + cmd[1:]
return launch_info
def _create_dependency_provider(self) -> LanguageServerDependencyProvider:
return self.DependencyProvider(self._custom_settings, self._ls_resources_dir)
@@ -116,8 +196,10 @@ class ClangdLanguageServer(SolidLanguageServer):
os.chmod(clangd_executable_path, 0o755)
return clangd_executable_path
def _create_launch_command(self, core_path: str) -> list[str] | str:
return [core_path]
def _create_launch_command(self, core_path: str) -> list[str]:
# --background-index enables clangd to index all files in the project,
# which is required for finding cross-file references
return [core_path, "--background-index"]
@staticmethod
def _get_initialize_params(repository_absolute_path: str) -> InitializeParams:
@@ -132,6 +214,11 @@ class ClangdLanguageServer(SolidLanguageServer):
"synchronization": {"didSave": True, "dynamicRegistration": True},
"completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}},
"definition": {"dynamicRegistration": True},
"references": {"dynamicRegistration": True},
"documentSymbol": {
"dynamicRegistration": True,
"hierarchicalDocumentSymbolSupport": True,
},
},
"workspace": {"workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}},
},
@@ -160,6 +247,7 @@ class ClangdLanguageServer(SolidLanguageServer):
await lsp.request_references(...)
# Shutdown the LanguageServer on exit from scope
# LanguageServer has been shutdown
```
"""
def register_capability_handler(params: Any) -> None:
@@ -213,10 +301,15 @@ class ClangdLanguageServer(SolidLanguageServer):
}
self.server.notify.initialized({})
# set ready flag
# set ready flag, clangd sends no meaningful notification when ready
# TODO This defeats the purpose of the event; we should wait for the server to actually be ready
self.server_ready.set()
# wait for server to be ready
self.server_ready.wait()
def _shutdown(self, timeout: float = 5.0) -> None:
"""Shutdown the clangd language server."""
# The serena compilation database persists in .serena/ for reuse
# Call parent shutdown
super()._shutdown(timeout=timeout)
+2 -2
View File
@@ -307,9 +307,9 @@ class Language(str, Enum):
return ClangdLanguageServer
case self.CPP_CCLS:
from solidlsp.language_servers.ccls_language_server import CclsLanguageServer
from solidlsp.language_servers.ccls_language_server import CCLS
return CclsLanguageServer
return CCLS
case self.PHP:
from solidlsp.language_servers.intelephense import Intelephense
@@ -1,12 +1,12 @@
[
{
"directory": ".",
"command": "g++ -I . -c a.cpp",
"command": "g++ -std=c++17 -I . -c a.cpp",
"file": "a.cpp"
},
{
"directory": ".",
"command": "g++ -I . -c b.cpp",
"command": "g++ -std=c++17 -I . -c b.cpp",
"file": "b.cpp"
}
]
]
+65 -13
View File
@@ -7,8 +7,8 @@ server is not available.
"""
import os
import pathlib
import shutil
from typing import cast
import pytest
@@ -17,18 +17,11 @@ from solidlsp.ls_config import Language
from solidlsp.ls_utils import SymbolUtils
def _clangd_available() -> bool:
return shutil.which("clangd") is not None
def _ccls_available() -> bool:
return shutil.which("ccls") is not None
# Build parametrize list based on availability
_cpp_servers: list[Language] = []
if _clangd_available():
_cpp_servers.append(Language.CPP)
_cpp_servers: list[Language] = [Language.CPP]
if _ccls_available():
_cpp_servers.append(Language.CPP_CCLS)
@@ -70,9 +63,9 @@ class TestCppLanguageServer:
assert add_symbol is not None, "Could not find 'add' function symbol in b.cpp"
sel_start = add_symbol["selectionRange"]["start"]
refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"] + 1)
ref_files = cast(list[str], [ref.get("relativePath", "") for ref in refs])
assert any("a.cpp" in ref_file for ref_file in ref_files), "Should find reference in a.cpp"
refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"])
ref_files = [ref.get("relativePath", "") for ref in refs]
assert any("a.cpp" in ref_file for ref_file in ref_files), f"Should find reference in a.cpp, {refs=}"
# Verify second call returns same results (stability check)
def _ref_key(ref: dict) -> tuple:
@@ -88,5 +81,64 @@ class TestCppLanguageServer:
e.get("character", -1),
)
refs2 = language_server.request_references(file_path, sel_start["line"], sel_start["character"] + 1)
refs2 = language_server.request_references(file_path, sel_start["line"], sel_start["character"])
assert sorted(map(_ref_key, refs2)) == sorted(map(_ref_key, refs)), "Reference results should be stable across calls"
@pytest.mark.parametrize("language_server", _cpp_servers, indirect=True)
@pytest.mark.xfail(
strict=True,
reason=("Both clangd and ccls do not support cross-file references for newly created files that were never opened by the LS."),
)
def test_find_references_in_newly_written_file(self, language_server: SolidLanguageServer) -> None:
# Create a new file that references the 'add' function from b.cpp
new_file_path = os.path.join("temp_new_file.cpp")
new_file_abs_path = os.path.join(language_server.repository_root_path, new_file_path)
try:
# Write the new file with a reference to add()
with open(new_file_abs_path, "w", encoding="utf-8") as f:
f.write(
"""
#include "b.hpp"
int use_add() {
int result = add(5, 3);
return result;
}
"""
)
# Open the new file so clangd knows about it
with language_server.open_file(new_file_path):
# Request document symbols to ensure the file is fully loaded by clangd
new_file_symbols = language_server.request_document_symbols(new_file_path).get_all_symbols_and_roots()
assert new_file_symbols, "New file should have symbols"
# Verify the file stays in open_file_buffers after the context exits
uri = pathlib.Path(new_file_abs_path).as_uri()
assert uri in language_server.open_file_buffers, "File should remain in open_file_buffers"
# Find the 'add' symbol in b.cpp
b_file_path = os.path.join("b.cpp")
symbols = language_server.request_document_symbols(b_file_path).get_all_symbols_and_roots()
symbol_list = symbols[0] if symbols and isinstance(symbols[0], list) else symbols
add_symbol = None
for sym in symbol_list:
if sym.get("name") == "add":
add_symbol = sym
break
assert add_symbol is not None, "Could not find 'add' function symbol in b.cpp"
# Request references for 'add'
sel_start = add_symbol["selectionRange"]["start"]
refs = language_server.request_references(b_file_path, sel_start["line"], sel_start["character"])
ref_files = [ref.get("relativePath", "") for ref in refs]
# Should find reference in the newly written file
assert any(
"temp_new_file.cpp" in ref_file for ref_file in ref_files
), f"Should find reference in newly written temp_new_file.cpp, {ref_files=}"
finally:
# Clean up the new file
if os.path.exists(new_file_abs_path):
os.remove(new_file_abs_path)