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

Greg Filak
Published

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 the GreeterLib target.
  • PUBLIC: This is the same keyword we've seen before, with the other options being INTERFACE or PRIVATE. A PUBLIC file set means the files and their associated properties are needed by both GreeterLib itself and by any consumer that links to it.
  • FILE_SET HEADERS: This declares that we are creating a file set named HEADERS. This name, in conjunction with the PUBLIC 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 the target_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 these BASE_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 and HEADER_SET properties into our ALL_SOURCES list for formatting. We'll add a current_target_files variable to help with this.
  • Our GreeterApp doesn't have any headers so, if get_target_property() returns an empty variable, we'll skip appending it to ALL_SOURCES. We can do this by adding EXISTS 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

CMakeLists.txt
cmake
Select a file to view its content

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 into PUBLIC or PRIVATE file sets.
  • Automated Include Directories: A PUBLIC or INTERFACE file set with BASE_DIRS specified automatically configures the include paths for consumers, replacing target_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 like clang-format.
  • Precise Installation: Use install(FILE_SET ...) to install only the public headers of a target, ensuring a clean and minimal distribution.
Next Lesson
Lesson 46 of 47

Generating Documentation with Doxygen

A step-by-step guide to integrating Doxygen into your CMake build process for automated C++ documentation.

Have a question about this lesson?
Answers are generated by AI models and may not be accurate