Using File Sets
An introduction to file sets and the target_sources() command, allowing us to manage our header files in a more controlled way
In the last lesson, we built a developer utility: a custom format target that automatically runs clang-format on our code. However, it has a flaw.
We collected the source files using get_target_property(... SOURCES), but this property only knows about the .cpp files we passed to add_library() or add_executable(). Our format target is completely unaware of our header files.
To solve this, we need a way for a target to formally declare all of its constituent files, including its public and private headers. This is the job of file sets.
Reviewing the GreeterLib Target
So far, we've been quite loose around how we handle header files. None of our CMake files list which headers our project has. We listed the directory where the header files are, but nowhere do we list the exact files associated with our target
greeter/CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(GreeterLib VERSION 1.0.0)
add_library(GreeterLib src/Greeter.cpp)
# We just specify a directory
target_include_directories(GreeterLib PUBLIC
"${CMAKE_CURRENT_SOURCE_DIR}/include"
)CMake is effectively unaware that Greeter.h exists. In larger projects, being explicit about what files are in our project and what CMake is responsible for managing is often preferred. Having a complete list of files would be useful for many build tasks (like our format target) and also prevents us from accidentally installing or packaging files we didn't intend.
Version 3.23 of CMake introduced the file sets feature to help with creating and managing these lists of files.
File Sets and the target_sources() Command
Let's replace our target_include_directories() to use this new file set technique via target_sources(). This command allows us to add files to a target after it has been created. It also lets us associate these files with a named file set.
greeter/CMakeLists.txt
cmake_minimum_required(VERSION 3.23)
project(GreeterLib VERSION 1.0.0)
add_library(GreeterLib src/Greeter.cpp)
target_include_directories(GreeterLib PUBLIC
"${CMAKE_CURRENT_SOURCE_DIR}/include"
)
target_sources(GreeterLib
PUBLIC
FILE_SET HEADERS
BASE_DIRS "include/"
FILES "include/greeter/Greeter.h"
)Let's break down this new command:
target_sources(GreeterLib ...): We are adding sources to theGreeterLibtarget.PUBLIC: This is the same keyword we've seen before, with the other options beingINTERFACEorPRIVATE. APUBLICfile set means the files and their associated properties are needed by bothGreeterLibitself and by any consumer that links to it.FILE_SET HEADERS: This declares that we are creating a file set namedHEADERS. This name, in conjunction with thePUBLICargument, helps CMake understand these files are the target's public headers.BASE_DIRS: This specifies the base directories for the file set. This is helpful in a few ways, but the immediate utility is that it lets us remove thetarget_include_directories()command. We're telling CMake that this directory is where some of our target's public headers are, so any target that links against us will need theseBASE_DIRSadded to their search paths.FILES: This is a list of the actual header files to include in the set.
With this change, GreeterLib now formally declares that Greeter.h is part of its public interface.
Multiple Files
If we had more header files, we would add them to our command, and can introduce white space to help with formatting if preferred:
target_sources(GreeterLib
PUBLIC
FILE_SET HEADERS#
BASE_DIRS "include/"
FILES
"include/greeter/Greeter.h"
"include/greeter/AnotherFile.h"
"include/greeter/AndAnother.h"
)Retrieving File Sets
Now that a target knows about its headers, how do we get that information back out? We can use get_target_property() just like we did for sources. The default set of headers we provided above is available as the HEADER_SET property:
cmake_minimum_required(VERSION 3.23)
project(GreeterLib VERSION 1.0.0)
add_library(GreeterLib src/Greeter.cpp)
target_sources(GreeterLib
PUBLIC
FILE_SET HEADERS
BASE_DIRS "include/"
FILES "include/greeter/Greeter.h"
)
get_target_property(HeaderList GreeterLib HEADER_SET)
message(STATUS "GreeterLib headers: ${HeaderList}")cmake ..-- GreeterLib headers: /project/greeter/include/greeter/Greeter.hExample 1: Using the File Set for Installation
The biggest benefit of explicitly listing all of our header files is that it allows for precise installation. Instead of installing an entire directory, we can tell CMake to install only the files in our set.
In our lesson on we installed our headers simply by copying everything in the /include directory:
install(DIRECTORY "${PROJECT_SOURCE_DIR}/include/"
DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}
FILES_MATCHING PATTERN "*.h" PATTERN "*.hpp"
)This worked, but what if we have some header files in that directory that we don't want to be shared? We can replace the imprecise install(DIRECTORY ...) with an exact installation rule for our file set.
This can be a new install() rule if we prefer, but it is more typically added to the existing rule:
install(
TARGETS GreeterLib
EXPORT GreeterLibTargets
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
# This replaces install(DIRECTORY ...)
FILE_SET HEADERS DESTINATION
${CMAKE_INSTALL_INCLUDEDIR}
)This will install only the PUBLIC and INTERFACE files that we defined as part of our HEADERS file set. It will also automatically handle creating the directory structure (include/greeter) at the destination, based on the BASE_DIRS we specified in target_sources().
A complete version of greeter/CMakeLists.txt with all of its install rules is provided below for reference:
greeter/CMakeLists.txt
cmake_minimum_required(VERSION 3.23)
project(GreeterLib VERSION 1.0.0)
include(GNUInstallDirs)
add_library(GreeterLib src/Greeter.cpp)
add_library(Greeter::Lib ALIAS GreeterLib)
set_target_properties(GreeterLib PROPERTIES
OUTPUT_NAME Greeter
EXPORT_NAME Lib
)
# This replaces target_include_directories()
target_sources(GreeterLib
PUBLIC
FILE_SET HEADERS
BASE_DIRS "${PROJECT_SOURCE_DIR}/include"
FILES
"${PROJECT_SOURCE_DIR}/include/greeter/Greeter.h"
)
install(
TARGETS GreeterLib
EXPORT GreeterLibTargets
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
# This replaces install(DIRECTORY ...)
FILE_SET HEADERS DESTINATION
${CMAKE_INSTALL_INCLUDEDIR}
)
install(EXPORT GreeterLibTargets
FILE GreeterLibTargets.cmake
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/GreeterLib
NAMESPACE Greeter::
)
include(CMakePackageConfigHelpers)
configure_package_config_file(
"cmake/GreeterLibConfig.cmake.in"
"GreeterLibConfig.cmake"
INSTALL_DESTINATION
"${CMAKE_INSTALL_LIBDIR}/cmake/GreeterLib"
)
write_basic_package_version_file(
"GreeterLibConfigVersion.cmake"
VERSION ${PROJECT_VERSION}
COMPATIBILITY AnyNewerVersion
)
install(FILES
"${CMAKE_CURRENT_BINARY_DIR}/GreeterLibConfig.cmake"
"${CMAKE_CURRENT_BINARY_DIR}/GreeterLibConfigVersion.cmake"
DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/GreeterLib"
)Example 2: Passing File Sets to clang-format
Let's fix the flaw in our format target from the previous lesson, where it was only formatting source files and not headers. We can fix this by updating our format target with the following changes:
- We'll now append files from both the
SOURCESandHEADER_SETproperties into ourALL_SOURCESlist for formatting. We'll add acurrent_target_filesvariable to help with this. - Our
GreeterAppdoesn't have any headers so, ifget_target_property()returns an empty variable, we'll skip appending it toALL_SOURCES. We can do this by addingEXISTSchecks to ensure we're only adding files that really exist.
Here's the updated logic in our root CMakeLists.txt:
CMakeLists.txt
cmake_minimum_required(VERSION 3.23)
project(Greeter)
add_subdirectory(app)
add_subdirectory(greeter)
find_program(CLANG_FORMAT_EXE clang-format)
set(FORMAT_TARGETS GreeterLib GreeterApp)
set(ALL_SOURCES)
foreach(target IN LISTS FORMAT_TARGETS)
get_target_property(target_sources ${target} SOURCES)
get_target_property(target_headers ${target} HEADER_SET)
get_target_property(target_source_dir ${target} SOURCE_DIR)
# Combine sources and headers into one list to process
set(current_target_files ${target_sources} ${target_headers})
foreach(file IN LISTS current_target_files)
if(IS_ABSOLUTE ${file} AND EXISTS ${file})
list(APPEND ALL_SOURCES ${file})
elseif(EXISTS ${target_source_dir}/${file})
list(APPEND ALL_SOURCES ${target_source_dir}/${file})
endif()
endforeach()
endforeach()
if(CLANG_FORMAT_EXE)
add_custom_target(format
COMMENT "Formatting files with clang-format..."
COMMAND ${CLANG_FORMAT_EXE} -i ${ALL_SOURCES}
)
message(STATUS "Added 'format' target")
else()
message(WARNING
"clang-format not found - format target unavailable")
endif()Our format target now correctly identifies and formats all .cpp and .h files that are explicitly associated with our targets.
File Set Types
We're currently calling our header file set HEADERS. This is a special name - when CMake sees a file set with this name, it implicitly understands that we're providing header files.
However, eventually, we'll need multiple file sets. A common reason is that we'll have both public headers and private headers, and we need to separate them into different sets. We'll cover this soon soon.
To help with this, we're free to call our file set anything we want - they don't need to be called HEADERS. However, if we're not using HEADERS, we need to provide additional arguments to inform CMake this this file sets is a list of header files.
Below, we rename our file set to public_headers, but we also pass TYPE HEADERS to help CMake understand what this set represents:
greeter/CMakeLists.txt
cmake_minimum_required(VERSION 3.23)
project(GreeterLib VERSION 1.0.0)
add_library(GreeterLib src/Greeter.cpp)
target_sources(GreeterLib
PUBLIC
FILE_SET public_headers
TYPE HEADERS
BASE_DIRS "include/"
FILES "include/greeter/Greeter.h"
)The HEADER_SET_<NAME> Property
Now that we're no longer using the default HEADERS name, our header files are not available in the HEADER_SET property. Our set is called public_headers, so the property we now need is called HEADER_SET_public_headers:
greeter/CMakeLists.txt
cmake_minimum_required(VERSION 3.23)
project(GreeterLib VERSION 1.0.0)
add_library(GreeterLib src/Greeter.cpp)
target_sources(GreeterLib
PUBLIC
FILE_SET public_headers
TYPE HEADERS
BASE_DIRS "include/"
FILES "include/greeter/Greeter.h"
)
get_target_property(
public_headers GreeterLib HEADER_SET_public_headers
)
message(STATUS "public_headers: ${public_headers}")
# Install rules...cmake .....
-- public_headers: /project-root/greeter/include/greeter/Greeter.h
...Any install() command we're using would also need updated to use this new name:
install(
TARGETS GreeterLib
EXPORT GreeterLibTargets
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
FILE_SET HEADERS DESTINATION
FILE_SET public_headers DESTINATION
${CMAKE_INSTALL_INCLUDEDIR}
)We'll update our format target to support this new name later in the lesson.
Private Headers
So far, we've covered PUBLIC headers, but what about headers that are internal implementation details? We can define a PRIVATE file set for them. These headers will be available for compiling GreeterLib itself, but they won't be exposed to consumers or included in the installation.
Let's add an internal header to our library and update Greeter.cpp to use it:
greeter/src/InternalDetails.h
#include <string>
namespace InternalDetails {
inline const std::string Name{
"the modular Greeting Library"
};
}We'd normally #include this file using a relative path (#include "InternalDetails.h") as it's in the same directory, but let's use chevrons in this contrived example to ensure that CMake is providing the required search path:
greeter/src/Greeter.cpp
#include <greeter/Greeter.h>
#include <string>
#include <InternalDetails.h>
std::string get_greeting() {
return "Hello from " + InternalDetails::Name;
}We can now update our target_sources() command to include this second file set:
greeter/CMakeLists.txt
cmake_minimum_required(VERSION 3.23)
project(GreeterLib VERSION 1.0.0)
add_library(GreeterLib src/Greeter.cpp)
target_sources(GreeterLib
PUBLIC
FILE_SET public_headers
TYPE HEADERS
BASE_DIRS "include/"
FILES "include/greeter/Greeter.h"
PRIVATE
FILE_SET private_headers
TYPE HEADERS
BASE_DIRS "src/"
FILES "src/InternalDetails.h"
)
# Install rules...Because this file set is PRIVATE, GreeterLib can find and compile InternalDetails.h, but consumers like GreeterApp will not have greeter/src added to their search paths.
This perfectly encapsulates our internal implementation detail.
The HEADER_SETS Property
With our earlier introduction of the public_headers and private_headers file sets, our GreeterLib target's headers are now spread across two properties: HEADER_SET_public_headers and HEADER_SET_private_headers.
We can access these properties in the usual way with get_target_properties(). However, the names of these properties are internal implementation details of GreeterLib so, to keep our code clean, we should be reluctant to directly use these names elsewhere in our project.
If GreeterLib adds or renames a file set, external code that uses those file sets shouldn't need to be updated. To help with this, targets have a HEADER_SETS property, which is a list of all of the file sets with TYPE HEADERS associated with that target:
greeter/CMakeLists.txt
cmake_minimum_required(VERSION 3.23)
project(GreeterLib VERSION 1.0.0)
add_library(GreeterLib src/Greeter.cpp)
target_sources(GreeterLib
PUBLIC
FILE_SET public_headers
TYPE HEADERS
BASE_DIRS "include/"
FILES "include/greeter/Greeter.h"
PRIVATE
FILE_SET private_headers
TYPE HEADERS
BASE_DIRS "src/"
FILES "src/InternalDetails.h"
)
get_target_property(
header_sets GreeterLib HEADER_SETS
)
message(STATUS "header_sets: ${header_sets}")
# Install rules...cmake .....
-- header_sets: public_headers;private_headers
...To get all of the header files associated with a target, we can loop over these HEADER_SETS:
greeter/CMakeLists.txt
cmake_minimum_required(VERSION 3.23)
project(GreeterLib VERSION 1.0.0)
add_library(GreeterLib src/Greeter.cpp)
target_sources(GreeterLib
PUBLIC
FILE_SET public_headers
TYPE HEADERS
BASE_DIRS "include/"
FILES "include/greeter/Greeter.h"
PRIVATE
FILE_SET private_headers
TYPE HEADERS
BASE_DIRS "src/"
FILES "src/InternalDetails.h"
)
get_target_property(
header_sets GreeterLib HEADER_SETS
)
foreach(set IN LISTS header_sets)
get_target_property(
header_files GreeterLib HEADER_SET_${set}
)
message(STATUS "set: ${set}, files: ${header_files}")
endforeach()
# Install rules...cmake ..-- set: public_headers, files: /project-root/greeter/include/greeter/Greeter.h
-- set: private_headers, files: /project-root/greeter/src/InternalDetails.hPractical Example: Updating the format Target
Over in our root CMakeLists.txt file, our format target is currently expecting all header files to be provided within the default HEADER_SET - the file set called HEADERS. Header files in any other file set, such as public_headers and private_headers, will not be formatted.
Updating our target to support this new technique of looping over each target's HEADER_SETS might look something like this:
CMakeLists.txt
cmake_minimum_required(VERSION 3.23)
project(Greeter)
add_subdirectory(app)
add_subdirectory(greeter)
find_program(CLANG_FORMAT_EXE clang-format)
set(FORMAT_TARGETS GreeterLib GreeterApp)
set(ALL_SOURCES)
foreach(target IN LISTS FORMAT_TARGETS)
get_target_property(target_sources ${target} SOURCES)
get_target_property(target_headers ${target} HEADER_SET)
get_target_property(header_sets ${target} HEADER_SETS)
get_target_property(target_source_dir ${target} SOURCE_DIR)
set(current_target_files ${target_sources} ${target_headers})
set(current_target_files ${target_sources})
foreach(header_set IN LISTS header_sets)
get_target_property(
header_set_files ${target} HEADER_SET_${header_set}
)
list(APPEND current_target_files ${header_set_files})
endforeach()
foreach(file IN LISTS current_target_files)
if(IS_ABSOLUTE ${file} AND EXISTS ${file})
list(APPEND ALL_SOURCES ${file})
elseif(EXISTS ${target_source_dir}/${file})
list(APPEND ALL_SOURCES ${target_source_dir}/${file})
endif()
endforeach()
endforeach()
if(CLANG_FORMAT_EXE)
add_custom_target(format
COMMENT "Formatting files with clang-format..."
COMMAND ${CLANG_FORMAT_EXE} -i ${ALL_SOURCES}
)
message(STATUS "Added 'format' target")
else()
message(WARNING
"clang-format not found - format target unavailable")
endif()Reducing CMakeLists.txt Bloat
Our root CMakeLists.txt file is starting to look very noisy, and almost all of its code is related to setting up this format target.
Remember, we can (and should) introduce to keep things organized.
Moving our formatting logic to a new add_clang_format() function defined in a different file might look something like this:
Files
Providing Sources
Currently, we're providing a target's source files (.cpp) as arguments to the add_library() or add_executable() commands.
We can also move that list to the target_sources() command, if we prefer. Source files are almost always listed as PRIVATE:
greeter/CMakeLists.txt
cmake_minimum_required(VERSION 3.23)
project(GreeterLib VERSION 1.0.0)
add_library(GreeterLib src/Greeter.cpp)
add_library(GreeterLib)
target_sources(GreeterLib
PRIVATE
src/Greeter.cpp
PUBLIC
FILE_SET public_headers
TYPE HEADERS
BASE_DIRS "include/"
FILES "include/greeter/Greeter.h"
PRIVATE
FILE_SET private_headers
TYPE HEADERS
BASE_DIRS "src/"
FILES "src/InternalDetails.h"
)
# Install rules...Summary
File sets are a modern, structured way to manage all the files associated with a target.
- File Sets are Explicit: The file set pattern requires us to be explicit about which headers are associated with a target, rather than just providing a directory where the headers are. This involves slightly more maintenance effort, but has several benefits.
- The
target_sources()Command: This is the primary command for adding files to a target and organizing them intoPUBLICorPRIVATEfile sets. - Automated Include Directories: A
PUBLICorINTERFACEfile set withBASE_DIRSspecified automatically configures the include paths for consumers, replacingtarget_include_directories(). - Tooling Integration: You can retrieve the list of files in a set with
get_target_property(), making it easy to pass a complete file list to tools likeclang-format. - Precise Installation: Use
install(FILE_SET ...)to install only the public headers of a target, ensuring a clean and minimal distribution.
Generating Documentation with Doxygen
A step-by-step guide to integrating Doxygen into your CMake build process for automated C++ documentation.