Forcing libraries to be linked in order to expose header-only dependencies.

Like many projects we many different libraries, executables, and tests that depend on different combinations of dependencies.

project/src/
- libA/*.{h,c}
- libB/*.{h,c}
- exeA depends_on libA
- testA depends_on lib{A,B}

At the top-level of our project source directory we use:

include_directories(${CMAKE_CURRENT_SOURCE_DIR})

So that targets in the tree can include headers easily like this

#include "libA/interface.h"

This has generally worked very well. But our project has grown now to the point where it is becoming too easy for unintentional header-only dependencies to be created. For example libA may depend on libB, but each library may include headers from the other–nothing prevents this mistake and now an unintentional circular dependency exists.

target_link_libraries(libA PUBLIC libB)

src/libA/header.h
  >> #include "libB/header.h"

src/libB/header.h
  >> #include "libA/header.h"

We could of course remove include_directories(${CMAKE_CURRENT_SOURCE_DIR}) and then re-organize the headers so that we have something like:

src/libA/include/libA/header.h
target_include_directories(libA libA/include)

So that the dependency has to be expressed explicitly in order to expose the headers from the dependent library.

However, we very much prefer to keep all of our source and headers in the same directory.

So I’ve been trying to come up with a way that we can do this dynamically by arrange for these headers to be staged/symlinked at configure-time into the binary directory and setup the target’s include directory in the same way.

I came up with this helper, which for the most part seems to work just fine. Here we do something like

add_cc_library(libA HDRS header.h SRCS src.cc)

And header.h will be includable as libA/header.h but only when the target is linked explicitly as a dependency.

My question is: Is there a better way to accomplish this? Are there big foot guns I’m not thinking about when considering this approach?

function(add_cc_library NAME)
    cmake_parse_arguments(add_cc_library "" "" "HDRS;SRCS;DEPS" ${ARGN})
    set(_NAME "add_${NAME}")

    cmake_path(GET CMAKE_CURRENT_LIST_DIR STEM include_prefix)
    cmake_path(APPEND include_path ${CMAKE_CURRENT_BINARY_DIR} ${NAME} ${include_prefix})

    set(manifest "${CMAKE_CURRENT_BINARY_DIR}/${NAME}.manifest")
    configure_file(${CMAKE_SOURCE_DIR}/cmake/manifest.in ${manifest} @ONLY)

    set(stamp "${manifest}.stamp")
    add_custom_command(
        OUTPUT ${stamp}
        COMMAND ${CMAKE_COMMAND} -E remove_directory ${include_path}
        COMMAND ${CMAKE_COMMAND} -E make_directory ${include_path}
        COMMAND ${CMAKE_COMMAND} -E touch ${stamp}
        DEPENDS ${manifest})

    set(staged_headers)
    foreach(header ${add_cc_library_HDRS})
        set(source_header ${CMAKE_CURRENT_LIST_DIR}/${header})
        set(staged_header ${include_path}/${header})
        list(APPEND staged_headers ${staged_header})
        add_custom_command(
            OUTPUT ${staged_header}
            COMMAND ${CMAKE_COMMAND} -E create_symlink ${source_header} ${staged_header}
            DEPENDS_EXPLICIT_ONLY
            DEPENDS ${stamp})
    endforeach()

    add_library(${_NAME} ${add_cc_library_SRCS} ${staged_headers})
    target_link_libraries(${_NAME} PUBLIC ${add_cc_library_DEPS})
    target_include_directories(${_NAME} PUBLIC ${CMAKE_CURRENT_BINARY_DIR}/${NAME})

    add_library("v::${NAME}" ALIAS ${_NAME})
endfunction()

Not while sharing source and header locations on disk where a single -I makes everything reachable.

The compiler will report warnings in the symlink files. While that is fine for symlinks in practice, note that Windows won’t support this well (this, of course, depends on how important Windows-hosted builds are for you). I also don’t know how much this messes with debuginfo being able to find things once installed.

1 Like

The compiler will report warnings in the symlink files.

Got it. I was (perhaps wrongly) under the impression that Bazel was playing this same symlink trick to accomplish the same goal. Perhaps it is simply ignoring this warning some how. FWIW when I was playing around with the approach I posted above I don’t recall observing any warnings.

I mean that if the header triggers a warning, it will report the symlink’s path, not the original file’s path.