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 theGreeterLib
target.PUBLIC
: This is the same keyword we've seen before, with the other options beingINTERFACE
orPRIVATE
. APUBLIC
file set means the files and their associated properties are needed by bothGreeterLib
itself 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 thePUBLIC
argument, 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_DIRS
added 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.h
Example 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 the exact install(FILE_SET ...)
.
install(FILE_SET GreeterLib 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 ...)
install(FILE_SET GreeterLib 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
SOURCES
andHEADER_SET
properties into ourALL_SOURCES
list for formatting. We'll add acurrent_target_files
variable to help with this. - Our
GreeterApp
doesn't have any headers so, ifget_target_property()
returns an empty variable, we'll skip appending it toALL_SOURCES
. We can do this by addingEXISTS
checks 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 will need updated to use this new name:
install(FILE_SET GreeterLib HEADERS
install(FILE_SET GreeterLib 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.h
Practical 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 intoPUBLIC
orPRIVATE
file sets. - Automated Include Directories: A
PUBLIC
orINTERFACE
file set withBASE_DIRS
specified 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.