Vim: Setting up a Build System and Code Completion for C and C++

A guide on integrating CMake and the YouCompleteMe engine with Vim

Introduction

This guide details how to turn Vim into a powerful C and C++ development environment. After working through each incremental step you will finish with a Vim configuration and boilerplate C++ project that you can use as a starting point for future applications. The steps presented in this guide will also work with Neovim.

CMake will be used to manage our application build process in a platform-independent way. We will also take a look at how to install and configure the YouCompleteMe Vim plug-in (from here referred to as YCM) to enable automatic code completion and error detection when working on source files.

Here’s a preview of how Vim will behave after completing this guide:

Preview of Vim running CMake and YCM.

Implementation details are broken down into six incremental sections where each one completes a specific task:

  1. Project Structure and Initial CMake Script
    We will install CMake and set up a project directory structure containing a simple C++ application. An initial CMake script will then be presented for compiling the application.
  2. YouCompleteMe Installation
    Detailed instructions are provided for installing the YCM code-completion engine on your Linux distribution. A configuration is also provided to improve the overall experience of running YCM with Vim.
  3. Setting Up Debug and Release Configurations
    The project’s directory structure and CMake script are modified to support debug and release builds of the boilerplate application. This section will also detail how to configure compiler flags with CMake.
  4. Vim Mappings for Automatic Building and Running
    This section will detail setting up some key mappings for automatically building and running the application. The vim-dispatch plug-in is utilized to run commands tied to the mappings asynchronously.
  5. Compiling and Linking a Dynamic Library
    A source file containing some mathematical functions is added to the project and compiled into a shared library. We use CMake to link to the shared library from our application.
  6. Final Tweaks
    The Vim configuration will be modified for setting a good color scheme.

This article recommends having a Vim configuration with some plug-ins already installed that accomplish some common editor tasks — such as having language packs for syntax highlighting, correct tabbing behavior, and a file system explorer. Having these features set up in advance will complement the functionality you will be integrating in this guide.

If you have a vanilla Vim installation without any plug-ins, I recommend going through my previous article which covers how to turn Vim into a lightweight IDE. As well as installing the Vundle package manager, it details how to configure a range of plug-ins to facilitate programming with Vim:

I have set up a GitHub repository for readers wishing to check out the final project detailed in this guide. You can download the repository by running the following git command:

$ git clone https://github.com/danebulat/vim-cmake-boilerplate

In addition to a boilerplate C++ application and build scripts, the repository also includes the corresponding Vim configuration file at config/vimrc. Keep in mind that the configuration does rely on some system packages for everything to work. Instructions for Installing YCM dependencies are provided in subsequent sections.

Project Structure and Initial CMake Script

The boilerplate project will have the following directory structure:

- root
--> bin # Executables
--> build # Build system files
--> config # Vim configuration
--> include # Application header files
--> lib # Library source files
--> src # Application source files

Common directory names used in C++ projects are adopted. Our solution also opts for out-of-source builds, meaning executables and shared libraries generated by the build system are placed outside of directories containing the actual source code.

The build directory will contain native build files generated by CMake after it reads the top-level CMakeLists.txt script that we write ourselves. Native Makefiles, executables and shared libraries will be placed in this directory when the project is built.

The config directory is a good location to store your corresponding Vim configuration file. This means a working configuration will always be available if you modify your live .vimrc file to a point where it breaks some functionality.

For readers implementing the project from scratch, navigate to an appropriate place on your file system and create the directory structure mentioned above:

$ mkdir vim-cmake-boilerplate && cd vim-cmake-boilerplate
$ mkdir bin build config include lib src

Let’s also add a simple main.cpp file in the src directory that we can build with CMake shortly:

The initial src/main.cpp file.

Moving forward, go ahead and install CMake using your distribution’s package manager if its not on your system already:

$ sudo pacman -S cmake      # Arch
$ sudo apt install cmake # Debian and Ubuntu
$ sudo dnf install cmake # Fedora and CentOS

I also recommend making a note of your CMake version and reading the short DESCRIPTION paragraph on its manual page:

$ cmake --version
-| cmake version 3.19.1 ...
$ man cmake

The manual describes CMake as a cross-platform build system generator — meaning we can write a single CMake script that will generate native build files on platforms such as Linux, Windows and Mac OS.

Reading further also reveals that the operating system’s native build tool can be invoked to build or install a project that uses CMake. This means that we could invoke the make utility on Linux to build our application after generating the necessary native build files with CMake. Alternatively, the cmake --build command can be used to do the same thing.

Lastly, CMake provides a GUI program to modify a project’s build configuration which can be invoked by running the cmake-gui command. Although I personally prefer using the ccmake command in a terminal, a GUI equivalent is also available.

With CMake installed we can write a short script to compile the main.cpp file and output an executable. We refer to this script as the top-level CMakeLists.txt file, and it is read by CMake to generate native build files for the system. Let's create a CMakeLists.txt file in the project's root directory containing some initial commands:

The initial CMakeLists.txt script.

Most of the commands are self-explanatory. We give the executable a name of Boilerplate and request C++ 11 standard features to build the target. The last command sets the RUNTIME_OUTPUT_DIRECTORY variable to the location of our project's bin directory. As a result, the generated executable will be placed in this location.

At this point we have done enough to generate an executable with the cmake utility. Let's test this out by running some commands in a terminal:

# Enter build directory
$ cd build
# Read CMakeLists.txt and generate native build files
$ cmake ..
# Build project and generate executable in bin/ directory
$ cmake --build .
# Run the executable
$ ../bin/Boilerplate
-| Hello, CMake!

The next thing we’ll do is generate a header file containing definitions that correspond to variables in CMakeLists.txt. Our application can include this header file and reference its definitions to do things like output build-specific information and compile code conditionally.

In our case, we will generate a header file called Config.h that contains definitions of the project's version number. This is done by calling the configure_file command in CMakeLists.txt using the syntax :

configure_file(<input> <output>)

The input file is similar to a template. It contains placeholders for variables that exist in CMakeLists.txt with the syntax @VARIABLE_NAME@. An output file will copy the input file and set the placeholders to actual values defined in our build configuration. Let's firstly create the input file at include/Config.h.in and define a couple of placeholders:

#pragma once
#define VERSION_MAJOR @Boilerplate_VERSION_MAJOR@
#define VERSION_MINOR @Boilerplate_VERSION_MINOR@

Then call the configure_file command in CMakeLists.txt:

   set(CMAKE_CXX_STANDARD_REQUIRED True)>> configure_file(include/Config.h.in 
"${CMAKE_CURRENT_SOURCE_DIR}/include/Config.h")

In order for CMake to find Config.h when the target is compiled, we must tell it to search the include directory:

   add_executable(Boilerplate 
"${CMAKE_CURRENT_SOURCE_DIR}/src/main.cpp")

>> target_include_directories(Boilerplate PUBLIC
"${CMAKE_CURRENT_SOURCE_DIR}/include")

Lastly, include Config.h in src/main.cpp and output the application's version:

The src/main.cpp program outputting its version number.

If you now update the version number in CMakeLists.txt and rebuild your project, the application will output the correct version number — there is no need to manually update any source code.

YouCompleteMe Installation

This section will go through installing the YCM plug-in for C and C++ development. YCM is a fast and powerful code completion engine that supports many languages. Its dependencies will vary depending on what language you want code completion for.

We will install and configure YCM to perform code completion for the C-family languages. The engine will provide IDE-like features, including:

  • A completion menu giving suggestions for anything we type in source files.
  • Function and method signature help and argument hints.
  • Detection of warning and errors that are presented in various ways.
  • Commands for refactoring and finding symbols.
  • Many configuration options to personalize the behavior of the plug-in.

This guide will provide enough information to get started with the YCM engine. With that said, multiple articles could be written going over all of its features and possible configurations. I certainly recommend browsing through the official YCM GitHub page to learn more about the plug-in.

The current build of YCM requires the minimum version of Vim to be v.8.1.2269. It is therefore a good idea to check that you are running a recent version of Vim on your system:

$ vim --version | awk 'NR==1 {print; exit}'
-| VIM - Vi IMproved 8.2 (2019 Dec 12, compiled ... 16:46:37)

The YCM documentation also recommends using the Vundle plug-in manager to install it — feel free to check out my previous article if you need help setting up Vundle. Although not mentioned in the YCM documentation, I’ve found that vim-plug also installs YCM without any problems.

Additionally, Python and a couple of packages that provide front-end compiler technologies are required by the YCM server. These packages can be installed with your distribution’s package manager:

# Arch
$ sudo pacman -S base-devel python clang llvm
# Debian and Ubuntu
$ sudo apt install build-essential python3-dev llvm-defaults
# Fedora
$ sudo dnf install gcc-c++ make python3-devel clang
clang-tools-extra

Please leave a note or comment If you are having trouble installing YCM on your distribution. I’ll investigate and update this guide with instructions accordingly!

With all dependencies taken care of we can now download the YCM plug-in with Vundle. Go ahead and add the ycm-core/YouCompleteMe repository to your plug-in list in .vimrc. Then refresh your configuration file before calling the :PluginInstall command:

" Add YCM repository to plugin list
Plugin 'ycm-core/YouCompleteMe'
" Refresh configuration
:source ~/.vimrc
" Download YCM
:PluginInstall

After :PlugInstall completes, exit Vim and navigate to the downloaded YCM repository on your file system. In addition to the main repository, several nested Git repositories called submodules are configured to work with YCM. In other words, these submodules need to be downloaded in order for the completion engine to function. You can check which submodules are configured for the YouCompleteMe repository by opening the .gitmodules file in the root directory:

$ cd ~/.vim/plugged/YouCompleteMe
$ cat .gitmodules
-| [submodule "third_party/requests-futures"]
path = third_party/requests-futures
url = https://github.com/ross/requests-futures
...

The path value specifies where the submodule will be cloned in the repository directory structure, and the url value identifies the remote location of the submodule. Run the following commands to download the submodules and complete the YCM installation:

# Clone submodules
$ git submodule update --init --recursive
# Install YCM
$ python3 install.py --clangd-completer

Notice that we specify the --clangd-completer option when invoking the install.py script. This means that YCM will provide semantic support (code completion) for C-family languages. You can provide additional flags to enable support for other languages, or specify the -all flag to enable everything. Keep in mind that doing this will require additional dependencies.

The YCM server will attempt to start the next time Vim is launched. However, it still needs to know how to compile our source files behind the scenes to analyze the code and give feedback. We solve this issue in the next section.

The YCM server will be able to compile source code and provide correct feedback when it detects a database of compiler flags in our project. We turn on the CMAKE_EXPORT_COMPILE_COMMANDS variable in CMakeLists.txt to generate a compiler flags database whenever the project is built:

   set(CMAKE_CXX_STANDARD_REQUIRED True)
>> set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

Now a file called compile_commands.json is generated and put in the build directory when we initiate a build - this file is the database we need:

$ cd build
$ cmake .. && cmake --build .
$ ls compile_commands.json
-| compile_commands.json

Lastly, this file needs to be located in the project’s root directory. The YCM documentation tells us to either make a copy, or create a symbolic link to the generated database. We will opt for the second option by creating a soft symbolic link to the actual build/compile_commands.json file:

$ cd .. && pwd
-| /home/dane/Github/vim-cpp-completion
$ ln -s build/compile_commands.json compile_commands.json

If Vim was open whilst you were generating the compiler flags database, restart the YCM server within the Vim editor so it detects the new file:

:YcmRestartServer

Now start typing some code to see the completion menu in action. Completion suggestions are based on a sub-sequence of what you type. Press the TAB key to cycle through items in the completion menu and Shift-TAB to cycle back. The default mapping to close the completion menu is <C-y>. Warnings and errors will also be presented in various ways:

Testing the default behavior of the YCM plug-in.

What YCM provides out of the box is great, but it would be nice to modify some settings. For example, making the help window (documentation panel) close automatically when we leave a highlighted item in the completion menu will make development more comfortable. You may also wish to modify the maximum amount of items displayed in the completion menu.

The following gist lists my YCM configuration. Feel free to copy these settings into your own .vimrc file while reading the comments to get an idea of what the setting does:

YCM configuration settings in .vimrc.

The editor will also “snap” a couple of characters to the right when YCM detects an error or warning in your source file. When this happens, the >> characters are rendered in the sign column. It is a good idea to always show the sign column in Vim to prevent this snapping behavior:

" Make Vim always render the sign column:
set signcolumn=yes

Setting Up Debug and Release Configurations

This section will discuss how to set up debug and release configurations for our project. The CMake BUILD_TYPE variable specifies which configuration is selected when the project is built, and is empty by default. Furthermore, flags defined in the CMAKE_CXX_FLAGS variable are selected when BUILD_TYPE is empty. Compiler flags can be observed in your CMake cache either via the terminal or CMake GUI program:

Checking the project’s default compiler flags.

You will notice CMAKE_CXX_FLAGS_DEBUG and CMAKE_CXX_FLAGS_RELEASE variables defined in the CMake cache and set to some default compiler flags:

  • -g : Generates debugging symbols. Should be used for debug builds during development.
  • -O3: Sets a high optimization level. Results in slower compile-time but faster production builds.
  • -DNDEBUG: Skips calls to assert functions.

The following subsections will detail how to configure our project to use the debug and release CMake configurations. We will also define our own compiler flags in CMakeLists.txt.

Lets update our project’s directory structure to accommodate individual build configurations by having nested debug and release directories in the bin and build directories:

# build directory
$ cd build && rm -fr *
$ mkdir Debug Release
# bin directory
$ cd ../bin && rm -fr *
$ mkdir Debug Release
$ cd ..

It is now possible to use the $<CONFIG> generator expression in the set_target_properties command for defining output directories for executables and libraries. The value of $<CONFIG> will be either Debug or Release when the project builds, making it possible to output executables in the correct location. Update the call to set_target_properties in CMakeLists.txt to set up this functionality:

set_target_properties(Boilerplate
PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${PROJECT_SOURCE_DIR}/bin/$<CONFIG>")

Now we must specify which configuration to use when the native build files are generated with CMake. For example, in the debug directory we would set the CMAKE_BUILD_TYPE variable to Debug when generating the native build files:

$ cd build/Debug
$ cmake -DCMAKE_BUILD_TYPE=Debug ../..
$ cmake --build .
$ ../../bin/Debug/Boilerplate
-| Running: ../../bin/Debug/Boilerplate
Version: 1.0
Hello, CMake!

Similarly, the CMAKE_BUILD_TYPE variable is set to Release when we want to generate native build files for a the release configuration:

$ cd ../Release
$ cmake -DCMAKE_BUILD_TYPE=Release ../..
$ cmake --build .
$ ../../bin/Release/Boilerplate
-| Running: ../../bin/Debug/Boilerplate
Version: 1.0
Hello, CMake!

The symbolic link pointing to the compiler flags database must also be re-linked because the build structure has been modified:

$ rm compile_commands.json
$ ln -s build/Debug/compile_commands.json compile_commands.json

YCM now compiles code using the debug flags in CMAKE_CXX_FLAGS_DEBUG. Restart the YCM server in Vim if necessary by running :YcmRestartServer to see the changes take effect.

Let’s now append some commands to CMakeLists.txt to pass some extra compilation flags to the compiler:

Compiler flags section in CMakeLists.txt.

Generator expressions are used here to select compiler flags based on the operating system. The flags defined in the gcc_flags variable are selected if a GCC-like compiler is detected on the system such as clang or c++ . We have also instructed CMake to select the -W3 flag if a Microsoft compiler is detected.

You may also want to add the -Werror and -Wunused compiler flags If your project is adopting very strict coding standards. Refer to the GNU GCC documentation if you need to brush up on the various warning options.

Since target_compile_features now requests the C++ 11 standard features to build the target, the following lines can be removed from CMakeLists.txt:

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_STANDARD_REQUIRED True)

Vim Mappings for Automatic Building and Running

This section will detail how to set up mappings to build and run the debug and release configurations from within Vim. It would be nice to be able to press a quick key combination to initiate the build process and run executables without having to drop to a command line and type everything manually.

Our solution relies on the vim-dispatch plug-in to execute commands asynchronously after a mapping is pressed. This allows us to continue working in the editor while building takes place in another terminal instance behind the scenes. Once a command completes, its corresponding terminal window can be opened to observe output. By doing this, any warnings or errors that may have occurred during the build can be checked.

Let’s start by adding the vim-dispatch repository to the plug-in list in .vimrc and installing it:

Plugin 'tpope/vim-dispatch'
:source ~/.vimrc
:PluginInstall

Make sure to restart Vim after vim-dispatch installs. From here we can define some mappings that will automatically build and run our application:

" Open vim-dispatch window and scroll to bottom
nnoremap <C-m>m :Copen<CR> <bar> G
" Build debug and release targets
nnoremap <C-m>bd :Dispatch! make -C build/Debug<CR>
nnoremap <C-m>br :Dispatch! make -C build/Release<CR>
  • <C-m>m opens the vim-dispatch window by invoking the :Copen command. The cursor scrolls to the last line in the window via the G key.
  • <C-m>bd invokes make to build the debug configuration. The -C flag specifies the directory to navigate to before initiating the build process.
  • <C-m>br is similar to <C-m>bd but builds the release configuration.

Feel free to define your own mappings for these commands. In the example above, initially pressing Ctrl+m signifies we are dealing with a make-related command. The subsequent presses of bd and br translate to "build debug" and "build release".

We also need mappings to run the project’s executables in the bin directory. The problem here is that Vim doesn't know where the executables are located or their corresponding file names. These properties will change depending on the project you are working on.

Our solution defines a couple of functions using Vimscript that enable us to specify the executable to run after pressing the corresponding key mapping. These functions are:

  • SetBinaryDebug(filename): The bin/Debug/<filename> executable is run when F6 is pressed.
  • SetBinaryRelease(filename): The bin/Release/<filename> executable is run when F7 is pressed.

This setup requires us to call SetBinaryDebug(..) and SetBinaryRelease(..) each time we launch Vim. However, we have full control over which executable is run for either the debug or release configurations. The function implementations in .vimrc are shown in here:

Functions in .vimrc for setting up F6 and F7 mappings.

Notice that the /bin/Release/ and /bin/Debug/ strings are appended to the output of the getcwd() function, which returns the current working directory. These functions can now be called at any time to set the executables that will run when F6 and F7 are pressed:

:call SetBinaryDebug("Boilerplate")
-| <F6> will run: /home/.../bin/Debug/Boilerplate
:call SetBinaryRelease("Boilerplate")
-| <F7> will run: /home/.../bin/Release/Boilerplate
# Can now press F6 and F7 to run the executables

Compiling and Linking a Dynamic Library

This section will take a quick look at using CMake to compile a shared library and linking it with the main executable. The shared library will be compiled from a single C++ source file that defines some easing functions which can be used for animating object properties. For example, you could link to this library if you are creating a game engine that requires smooth easing animations. A corresponding header file will also be added for enabling other source files to reference functions contained in the shared library.

Our project will put the shared library source files in a nested directory under lib. This nested directory will have the same name as the library. In this demonstration, we will name the shared library interp for interpolation. With this knowledge, we can set up our new interp directory and download the source files from this guide’s repository using curl:

$ cd lib && mkdir interp && cd interp$ curl -L https://raw.githubusercontent.com/danebulat/vim-cmake-boilerplate/master/lib/interp/interpolate.h > interpolate.h$ curl -L https://raw.githubusercontent.com/danebulat/vim-cmake-boilerplate/master/lib/interp/interpolate.cpp > interpolate.cpp

Looking at the source code, our library defines nine functions which correspond to a particular easing function. I recommend referring to this website if you are interested in implementing other easing functions in your projects.

The next thing we need to do is create a CMakeLists.txt file inside the interp directory that calls the appropriate commands for compiling the interpolate.cpp source file into a shared library:

The lib/interp/CMakeLists.txt script.

The target_include_directories command exposes the interp directory’s source files to targets that link against the shared library. This enables CMake to locate interpolate.h when it is included in other source files.

From here, the top-level CMakeLists.txt file needs some modification. The BUILD_SHARED_LIBS CMake variable firstly needs to be turned on to build shared libraries by default. The add_subdirectory command must also be called to make CMake read the lib/interp/CMakeLists.txt file:

   set(CMAKE_EXPORT_COMPILE_COMMANDS ON)>> option(BUILD_SHARED_LIBS "Build using shared libraries" ON)
>> add_subdirectory(lib/interp)

Moving forward, the target_link_libraries command is called after the executable is generated to link the interp shared library:

   add_executable(Boilerplate 
"${CMAKE_CURRENT_SOURCE_DIR}/src/main.cpp")
>> target_link_libraries(Boilerplate interp)

We also tell CMake to select the same compiler flags as the ones used to compile the main executable at the bottom of CMakeLists.txt:

target_compile_options(interp PRIVATE
"$<${gcc_like_cxx}:$<BUILD_INTERFACE:${gcc_flags}>>"
"$<${msvc_cxx}: $<BUILD_INTERFACE:${msvc_flags}>>")

From here I recommend cleaning your bin and build directories before re-building the whole project:

# Clean build and bin directories
$ cd build && rm -fr * && mkdir Debug Release
$ cd ..
$ cd bin && rm -fr * && mkdir Debug Release
$ cd ..
# Build debug configuration and re-link compiler flags database
$ cd build/Debug && cmake -DCMAKE_BUILD_TYPE=Debug ../..
$ cmake --build .
$ cd ../..
$ rm compile_commands.json
$ ln -s build/Debug/compile_commands.json compile_commands.json
# Configure release configuration
$ cd build/Release
$ cmake -DCMAKE_BUILD_TYPE=Release ../..
$ cd ../..

With the build system set up and shared library generated, we can now access the shared library functions inside src/main.cpp:

The final src/main.cpp file.

Lastly, the ldd tool can be invoked on the command line to confirm that the executable links to the interp shared library:

$ ldd bin/Debug/Boilerplate
-| libinterp.so => <path to shared library>

Final Tweaks

This short section goes over how to download and configure a nice color scheme for Vim. The screen shots and GIFs presented in this guide show Vim rendering the Edge color scheme. Feel free to add its repository to your plug-in list in .vimrc and install it with the usual commands:

Plugin 'sainnhe/edge'
:source ~/.vimrc
:PluginInstall

The next code snippet shows my Edge configuration. I have turned off italics and enabled true color rendering (16 million colors) if the terminal running Vim supports it. The bottom section sets the font to Hack, size 11 — make sure to specify a font that is installed on your system. The last line sets Edge as Vim’s default color scheme:

let g:edge_style = 'aura'
let g:edge_enable_italic = 0
let g:edge_disable_italic_comment = 1
if has('termguicolors')
set termguicolors
endif
set guifont=Hack\ 11
set background=dark
colorscheme edge

I also recommend checking out my powerline integration guide if you would like to render a fancy Vim status line:

In Conclusion

Vim is now configured to facilitate the development of large-scale C and C++ applications efficiently. A code completion engine is installed that provides IDE features that are fully customizable. In addition, the build system can grow naturally with the project by adding multiple CMakeList.txt files and calling the appropriate CMake commands.

MSc. Programmer and fan of open source software.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store