After I published Dockerized build environments for C/C++ projects, I was approached on a few occasions, both online and offline, with questions regarding my IDE/text editor setup.

While IDEs and text editors provide different features, the most important, from my point of view, are syntax highlighting, auto-completion, and code navigation. Since a single software usually provides all three, I will call it just auto-completion. I prefer to do things like building and debugging directly from the terminal; I will discuss performing these functions via VS Code later in the post.

Auto-completion types

Auto-completion is probably one of the most important things a modern IDE or text editor may provide. There are several types of autocompletion mechanisms: some are more accurate but robust, while others are more precise but require more fine-tuning.

  • Index/parser based is quite simple: there is an application that parses the code and indexes it; later, when auto-completion is triggered, the application looks up a symbol in the index database. Probably, the most famous implementation is ctags and its descendants. The major problem with such tools is that it is tough to write a good parser for C++, which often makes the index database and cross-referencing between files in a project inaccurate. Index-based tools are agnostic to dockerized builds.

  • Tree-sitter is a lightweight parser that builds an abstract syntax tree (AST) for each source file. The tree is used to collect data about text objects in source files; it is stored in a database or a memory and used to provide information for auto-completion. Unfortunately, its autocompletion capabilities are quite limited at the moment, mostly because it has no information about the build system and parses every file individually. However, it is widely used as a data provider for extended syntax highlighting. This mechanism is also insensitive to dockerized builds.

  • Compiler-based is more complicated: it utilizes the compiler’s original parser to build an abstract syntax tree (AST), which is used for indexing. As a result, the autocompletion metadata is collected by the best language parser in the world, the compiler. Usually, this kind of tool provides the most accurate information about the source for auto-completion. Also, it is often the heaviest option. Almost always affected by the dockerized builds.

  • Hybrid is a mixture of the above.

Since the accuracy of autocompletion is extremely important to me, I always prefer using compiler-based tools.

Clangd

The most prominent example of a compiler-based autocompletion parser for C++ is clangd; it is based on the Clang C++ compiler and part of the LLVM project. Once the clangd server is launched, it looks for a file compile_commands.json, which contains a list of files in the project together with the compiler flags. To instruct CMake to generate this file, add the following to the top-level CMakeLists.txt file:

1
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

For information about other build systems and customized configurations, please refer to the official documentation.

Usually, I add a symlink to this file from the top-level folder in the source tree:

1
2
3
4
cd Project/
ln -s ./build/compile_commands.json compile_commands.json
ls -l compile_commands.json
lrwxrwxrwx 1 dima dima 36 Aug  2 17:13 compile_commands.json -> ./build/compile_commands.json

For the example from Dockerized build environments for C/C++ projects, the generated compile_commands.json looks like the following:

1
2
3
4
5
6
7
[
{
  "directory": "/example/build",
  "command": "/usr/bin/g++  -DBOOST_ALL_NO_LIB -I/example -isystem /usr/local/include    -Wall -Wbuiltin-macro-redefined -pedantic -Werror -std=c++1z -o CMakeFiles/a.out.dir/main.cpp.o -c /example/main.cpp",
  "file": "/example/main.cpp
}
]

The JSON contains three entries per each file:

  • Path to the directory where compile_commands.json files are located
  • Compile commands, including “defines”, “include” paths, C++ standard version, flags, etc.
  • Path to the C++ file

Note that all the paths are absolute, meaning that clangd has to run on the same filesystem as the build system. This becomes a problem when building in docker: the clangd server runs on the host while the absolute path to source files and “includes” is found in the docker container’s filesystem. We will come back to this point later on.

After the file is loaded, the server parses each file, builds its AST, collects metadata about variables and functions usage, #defines and #if-defs related to this file and others, and stores it in the .cache folder. Note that this folder is created in the same directory where compile_commands.json is located. The parsed data is stored per cpp file in a *.idx format:

1
2
-rw-r--r-- 1 dima dima 7.6K Sep 15 11:47 robot.cpp.5254FE304AF08338.idx
-rw-r--r-- 1 dima dima  26K Sep 18 09:13 spaceship.h.78C478E3644A6BF2.idx

Fortunately, clangd watches the files and updates the index in the background.

VS Code with Clangd

The first time I used Clangd was via an amazing Vim plugin YouCompleteMe. Later, I switched to Neovim and continued using clangd as an LSP server. A few months ago, I switched to VS Code with the VSCode Neovim plugin. I confess I am addicted to Vim shortcuts.

To enable clangd in VS Code, we first need to install the official LLVM extension. Once installed, you will be prompted to install the latest clangd server, which is strongly suggested. While the setup thus far would be enough for natively built projects, dockerized builds would require an extra step.

VS Code with Docker Support

VS Code provides Docker support via the Remote - Containers extension. A Docker container can be used as a full-featured development environment, where any folder can be opened inside (or mounted into) a container and Visual Studio Code’s complete suite of features are at the user’s disposal. A file .devcontainer.json in the project’s top-level directory tells VS Code to re-load it in a Docker container. For our example project, it will look like the following:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
 "image": "example/example_build:latest",
 "runArgs": [
  "--cap-add=SYS_PTRACE",
  "--security-opt",
  "seccomp=unconfined"
 ],
 "customizations": {
  "vscode": {
   "extensions": [
    "llvm-vs-code-extensions.vscode-clangd",
   ]
  }
 },
 "workspaceMount": "source=${localWorkspaceFolder},target=/example,type=bind",
 "workspaceFolder": "/example",
 "onCreateCommand": "apt update && apt install -y git && git config --global --add safe.directory /example"
}

where:

  • "image" - the image we use for building the project
  • "runArgs" - represents the flags provided to docker run for launching the container for auto-completion. In this example, I added the basic flags required for debugging in Docker.
  • "extensions" - a list of VS Code extensions to be installed in the container. I have added only clangd, but more will be added soon.
  • "workspaceMount" - mounting point; in our case, we mount the current directory to the directory example in the container, the same way we did for the build container.
  • "onCreateCommand" is a bit ugly: the build container comes without Git, so I had to install it every time the auto-completion container is created. I believe there is a cleaner way to do it, but this works fine for now.

After the Docker extension is installed and the .devcontainer.json file is placed in the repository, VS Code proposes to re-open the project in the container:

Open in container

Once the user agrees, VS Code launches a Docker container based on the "image" and adds the extensions specified in "extensions". Since the base image does not contain clangd, VS Code will ask to install it; it is strongly recommended to proceed.

Install Clangd

Once installed and reloaded, VS Code launches the clangd server, which can be confirmed by checking the source tree for the .cache directory with the index database, and the auto-completion works like a charm.

Auto-completion with the cache folder

Additionally, clangd provides error and warning messages based on compiler diagnostics and clang-tidy configuration (if it exists):

Compiler diagnostics

It also provides fix suggestions for cases when the compiler can help:

Fix suggestion

The last noteworthy feature is the support of format based on the .clang-format file.

Further Improvements

After critical setup is completed, other valuable extensions can be added to the .devcontainer.json. It might be crucial because once launched in Docker, all the interaction with source code is done from within a container, i.e., if a user uses any plugins for editing file types other than C++ within their project, it is recommended to add the corresponding plugins to the .devcontainer.json file.

For example, CMake files are not highlighted properly; this is remedied by installing the CMake plugin in the container, as shown in the picture below:

Install CMake in Container

After the plugin is installed, I suggest adding it to the .devcontainer.json file either manually or via the UI so that the next time the container is launched, the extension will be installed automatically:

Add to devcontainer

Besides those mentioned, my setup usually includes various plugins, depending on the project: Python plugin(s), Grammarly (checks grammar in text and markdown files), Git Lens, markdown linter, and others.

Some users might find the CodeLLDB extension useful, which provides a native debugger powered by LLDB to debug C++, Rust, and other compiled languages.

Additional information

For complete information regarding the configurations available for the .devcontainer.json file, please refer to the official page.

As usual, the example project on Github is updated with the docker configuration described in this post.

Special thanks to Rina Volovich for editing.