YouCompleteMe and CMake

Tags: programming

Published on
« Previous post: Some leaky abstractions in C++11 — Next post: Baby steps with libclang: Walking an … »

I recently updated my vim configuration to include the fine YouCompleteMe plugin for C++ development. Since the software project I am mostly working on during my PhD project uses CMake, configuring YCM turned out to require some additional steps.

First, I added

SET( CMAKE_EXPORT_COMPILE_COMMANDS ON )

to the main CMakeLists.txt file of our project. This ensures that CMake creates the file compile_commands.json in the build directory of the project. Next, we need CMake to provision the file properly:

IF( EXISTS "${CMAKE_CURRENT_BINARY_DIR}/compile_commands.json" )
  EXECUTE_PROCESS( COMMAND ${CMAKE_COMMAND} -E copy_if_different
    ${CMAKE_CURRENT_BINARY_DIR}/compile_commands.json
    ${CMAKE_CURRENT_SOURCE_DIR}/compile_commands.json
  )
ENDIF()

This places the file in the source directory, permitting us to place a single configuration file for YCM there. I have to add that I am not very happen with this solution yet. For one, this removes the ability to have a debug build in some location and a release build somewhere else because the build that is configured last gets to copy its compile commands—which of course tend to differ with different configuration options. Another reason why this solution is not entirely smart is that the copy is usually performed prior to CMake updating the compile_commands.json file. If you change many compile options for different files, you will thus have to manually run the cmake binary twice.

Even with all these quirks, it’s still worth it—YCM is just so amazing to have. Being a previous user of the QtCreator IDE, I am blown away by the speed with which the plugin manages to perform its duties.

As a last step, we need to place a .ycm_extra_conf.py configuration file in the project folder. By default, YCM will use a pre-defined list of compilation flags to apply to all files. This is of course insufficient for most larger projects, so we need to teach it to use the compile_commands.json instead.

This is easily achieved with the following configuration file:

import os
import ycm_core

from clang_helpers import PrepareClangFlags

def DirectoryOfThisScript():
    return os.path.dirname(os.path.abspath(__file__))

# This is the single most important line in this script. Everything else is just nice to have but
# not strictly necessary.
compilation_database_folder = DirectoryOfThisScript()

# This provides a safe fall-back if no compilation commands are available. You could also add a
# includes relative to your project directory, for example.
flags = [
    '-Wall',
    '-std=c++11',
    '-stdlib=libc++',
    '-x',
    'c++',
    '-I',
    '.',
    '-isystem', '/usr/local/include',
    '-isystem', '/usr/include',
    '-I.',
]

if compilation_database_folder:
    database = ycm_core.CompilationDatabase(compilation_database_folder)
else:
    database = None

SOURCE_EXTENSIONS = [ '.cpp', '.cxx', '.cc', '.c', '.m', '.mm' ]

def MakeRelativePathsInFlagsAbsolute( flags, working_directory ):
  if not working_directory:
    return list( flags )
  new_flags = []
  make_next_absolute = False
  path_flags = [ '-isystem', '-I', '-iquote', '--sysroot=' ]
  for flag in flags:
    new_flag = flag

    if make_next_absolute:
      make_next_absolute = False
      if not flag.startswith( '/' ):
        new_flag = os.path.join( working_directory, flag )

    for path_flag in path_flags:
      if flag == path_flag:
        make_next_absolute = True
        break

      if flag.startswith( path_flag ):
        path = flag[ len( path_flag ): ]
        new_flag = path_flag + os.path.join( working_directory, path )
        break

    if new_flag:
      new_flags.append( new_flag )
  return new_flags


def IsHeaderFile( filename ):
  extension = os.path.splitext( filename )[ 1 ]
  return extension in [ '.h', '.hxx', '.hpp', '.hh' ]


def GetCompilationInfoForFile( filename ):
  # The compilation_commands.json file generated by CMake does not have entries
  # for header files. So we do our best by asking the db for flags for a
  # corresponding source file, if any. If one exists, the flags for that file
  # should be good enough.
  if IsHeaderFile( filename ):
    basename = os.path.splitext( filename )[ 0 ]
    for extension in SOURCE_EXTENSIONS:
      replacement_file = basename + extension
      if os.path.exists( replacement_file ):
        compilation_info = database.GetCompilationInfoForFile(
          replacement_file )
        if compilation_info.compiler_flags_:
          return compilation_info
    return None
  return database.GetCompilationInfoForFile( filename )


def FlagsForFile( filename, **kwargs ):
  if database:
    # Bear in mind that compilation_info.compiler_flags_ does NOT return a
    # python list, but a "list-like" StringVec object
    compilation_info = GetCompilationInfoForFile( filename )
    if not compilation_info:
      return None

    final_flags = MakeRelativePathsInFlagsAbsolute(
      compilation_info.compiler_flags_,
      compilation_info.compiler_working_dir_ )

  else:
    relative_to = DirectoryOfThisScript()
    final_flags = MakeRelativePathsInFlagsAbsolute( flags, relative_to )

  return {
    'flags': final_flags,
    'do_cache': True
  }

The single most important line of this script is where you specify the compilation commands data base, compilation_database_folder = DirectoryOfThisScript(). Apart from the that, the script is a modified version of the default YCM configuration file, which the author strongly suggests to modify.

A last word of advice: Depending on your YCM configuration, you may want to add let g:ycm_extra_conf_globlist = [ '/path/to/your/project/*' ] to your .vimrc. This ensures that YCM will load the configuration file automatically and not let you confirm anything due to security reasons.

Happy code completing!