CMake Commands, Arguments, and Lists
Exploring CMake's command syntax and its list data type, including how to create, manipulate, and use lists in your projects.
In the last two lessons, we learned about variables in CMake. We saw how normal variables let us store and reuse information within a single configuration run, and how cached variables let us create persistent, user-configurable options.
This lesson will explore the fundamental syntax of CMake commands in a bit more depth, and introduce the idea of a list, which is how we represent collections of values.
CMake Commands and Arguments
The basic syntax of any CMake script is a series of commands. Every command follows the same pattern:
command_name(argument1 argument2 ...)
Here are the key rules to remember:
- Case-Insensitive Commands: Command names are case-insensitive.
project()
,PROJECT()
, andProject()
are all treated as the same command. However, the strong convention is to always use lowercase for command names. This makes your scripts consistent and easier to read. - Case-Sensitive Arguments: In contrast, arguments - including variable names, target names, and file paths - are almost always case-sensitive.
GreeterApp
is a different target fromgreeterapp
. - Whitespace Separation: Arguments are separated by one or more spaces or newlines.
Quoted vs. Unquoted Arguments
This brings us to an important point about how CMake parses arguments. Let's look at two set()
commands:
# 1. An unquoted argument
set(UNQUOTED_VAR Hello World)
# 2. A quoted argument
set(QUOTED_VAR "Hello World")
These two commands do very different things.
QUOTED_VAR
becomes a single string:"Hello World"
.UNQUOTED_VAR
becomes a list of two strings:"Hello"
and"World"
.
When you provide multiple unquoted arguments, CMake treats them as a collection of separate items. This implicit list creation is a core feature of the language and is the most common way lists are formed.
Understanding Lists
So, what exactly is a CMake list? It is simply a single string where the elements are separated by semicolons.
When we wrote set(UNQUOTED_VAR Hello World)
, CMake created the string "Hello;World"
. The set()
command automatically joined the unquoted arguments with semicolons for us.
We can also create lists explicitly:
# This creates a list with three elements.
set(MY_SOURCES "main.cpp;utils.cpp;logger.cpp")
This is exactly equivalent to:
# This also creates a list with three elements.
set(MY_SOURCES main.cpp utils.cpp logger.cpp)
The semicolon-separated string is the "true" form of a list. The space-separated set()
command is just convenient syntax for creating one, and is the preferred approach.
Passing Lists to Commands
When you pass a list variable to a command, you typically do it without quotes.
set(MY_SOURCES main.cpp utils.cpp)
# Correct: Pass unquoted to expand the list
add_executable(MyApp ${MY_SOURCES})
When CMake sees ${MY_SOURCES}
, it substitutes the variable's value ("main.cpp;utils.cpp"
) and then expands it. The semicolons act like spaces, breaking the string into multiple arguments for the add_executable()
command.
The command effectively becomes:
add_executable(MyApp main.cpp utils.cpp)
This is what you almost always want. If you were to quote the variable (add_executable(MyApp "${MY_SOURCES}")
), you would be passing a single string "main.cpp;utils.cpp"
as the only source file. This would almost certainly fail because no file with that name likely exists.
Manipulating Lists
CMake provides a list()
command for performing a wide range of operations on these semicolon-separated strings. The list()
command always takes the list variable name as its first argument, followed by a subcommand that specifies the action to take.
Let's explore the most common list()
subcommands.
Adding Elements
This is the most common list operation. It adds one or more elements to the end of a list.
set(FILES file1.cpp file2.cpp)
message("Original list: ${FILES}")
list(APPEND FILES file3.cpp file4.cpp)
message("Appended list: ${FILES}")
Original list: file1.cpp;file2.cpp
Appended list: file1.cpp;file2.cpp;file3.cpp;file4.cpp
Getting the Size
To find out how many elements are in a list, use list(LENGTH)
. It stores the result in an output variable.
set(FILES file1.cpp file2.cpp file3.cpp)
list(LENGTH FILES NUM_FILES)
message("The list has ${NUM_FILES} elements.")
The list has 3 elements.
Accessing by Index
Lists are 0-indexed. You can retrieve an element at a specific index with list(GET)
or remove one with list(REMOVE_AT)
.
set(COLORS red green blue)
list(GET COLORS 1 MIDDLE_COLOR)
message("The middle color is: ${MIDDLE_COLOR}")
list(REMOVE_AT COLORS 0)
message("After removing first element: ${COLORS}")
The middle color is: green
After removing first element: green;blue
Ordering
These commands modify the list in-place to change its order.
set(LETTERS c a d b)
message("Original: ${LETTERS}")
list(SORT LETTERS)
message("Sorted: ${LETTERS}")
list(REVERSE LETTERS)
message("Reversed: ${LETTERS}")
Original: c;a;d;b
Sorted: a;b;c;d
Reversed: d;c;b;a
Searching
To check if an element exists in a list, use list(FIND)
. It returns the index of the first match, or -1
if the element is not found.
set(TOOLS hammer saw screwdriver)
list(FIND TOOLS "saw" INDEX_OF_SAW)
message("Index of 'saw' is: ${INDEX_OF_SAW}")
Index of 'saw' is: 1
This is particularly useful when combined with if()
statements, which we'll cover in the next lesson.
A Practical Example
A common practice is to define your source files in a list variable before passing them to commands like add_library()
or add_executable()
. This is highly recommended if your list is long, as the intermediate variable then keeps your target definitions clean.
There is also a practical reason to introduce this list variable, as it allows our script to use the list()
commands we covered to modify our list of files before providing them to our target.
Let's refactor our GreeterApp
's CMakeLists.txt
in /app
to use this pattern:
app/CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
option(
SAY_GOODBYE
"Should the program say goodbye before exiting"
OFF
)
set(GREETER_SOURCES
src/main.cpp
# Add more source files here as needed
# src/some-other-file.cpp
)
add_executable(GreeterApp src/main.cpp)
add_executable(GreeterApp ${GREETER_SOURCES})
target_compile_features(GreeterApp PUBLIC cxx_std_20)
target_link_libraries(GreeterApp GreeterLib)
if(SAY_GOODBYE)
message(STATUS "Defining SAY_GOODBYE")
target_compile_definitions(GreeterLib PUBLIC SAY_GOODBYE)
endif()
So far, the behavior is identical.
However, let's imagine the SAY_GOODBYE
cache variable we defined in the previous lesson is set to ON
, and our GreeterApp
needs some additional source files to implement that behaviour.
We can now conditionally APPEND
those source files to our list before forwarding it to our target:
Files
Let's test everything works. From the /build
folder as usual, we'll run the configure step:
cmake -DSAY_GOODBYE=ON ..
-- Configuring Greeter Version: 1.0
-- Adding goodbye.cpp to GREETER_SOURCES
-- GREETER_SOURCES now has 2 files
-- Defining SAY_GOODBYE
-- Configuring done (0.1s)
-- Generating done (0.1s)
-- Build files have been written to: D:/Projects/cmake/build
We'll then build our project:
cmake --build .
And run it in the usual way, remembering to add .exe
if on Windows:
./app/GreeterApp
Hello from the modular Greeter library!
Goodbye
Summary
In this lesson, we dug into the grammar of the CMake language and learned about its collection type - lists.
- Command Syntax: Commands are case-insensitive, but their arguments are not.
command(arg1 arg2)
is the basic structure. - Quoted vs. Unquoted Arguments: Unquoted arguments are treated as separate items, implicitly forming a list. Quoted arguments are treated as a single string.
- Lists as Semicolon-Separated Strings: A CMake list is just a string with semicolons as delimiters (e.g.,
"a;b;c"
). - List Expansion: Passing a list variable unquoted (e.g.,
${MY_LIST}
) to a command expands it into multiple arguments. This is the standard way to use lists. - The
list()
Command: This is your primary tool for list manipulation, with subcommands likeAPPEND
,LENGTH
,GET
,SORT
, andFIND
. - Best Practice: Group your source files into list variables before passing them to commands like
add_library()
oradd_executable()
to keep your scripts clean and maintainable.
You now have a solid understanding of how CMake handles commands, arguments, and collections of data.
CMake Conditionals and Loops
Transform your static build descriptions into dynamic scripts with CMake's control flow commands, if() and foreach().