Step 2: CMake Language Fundamentals

In the previous step we rushed through and handwaved several aspects of the CMake language which is used within CMakeLists.txt in order to get useful, building programs as soon as possible. However, in the wild we encounter a great deal more complexity than simply describing lists of source and header files.

To deal with this complexity CMake provides a Turing-complete domain-specific language for describing the process of building software. Understanding the fundamentals of this language will be necessary as we write more complex CMLs and other CMake files. The language is formally known as "CMake Language", or more colloquially as CMakeLang.

Note

The CMake Language is not well suited to describing things which are not related to building software. While it has some features for general purpose use, developers should use caution when solving problems not directly related to their build in CMake Language.

Oftentimes the correct answer is to write a tool in a general purpose programming language which solves the problem, and teach CMake how to invoke that tool as part of the build process. Code generation, cryptographic signature utilities, and even ray-tracers have been written in CMake Language, but this is not a recommended practice.

Because we want to fully explore the language features, this step is an exception to the tutorial sequencing. It neither builds on Step1, nor is the starting point for Step3. This will be a sandbox to explore language features without building any software. We'll pick back up with the Tutorial program in Step3.

Note

This tutorial endeavors to demonstrate best practices and solutions to real problems. However, for this one step we're going to be re-implementing some built-in CMake functions. In "real life", do not write your own list(APPEND).

Background

The only fundamental types in CMakeLang are strings and lists. Every object in CMake is a string, and lists are themselves strings which contain semicolons as separators. Any command which appears to operate on something other than a string, whether they be booleans, numbers, JSON objects, or otherwise, is in fact consuming a string, doing some internal conversion logic (in a language other than CMakeLang), and then converting back to a string for any potential output.

We can create a variable, which is to say a name for a string, using the set() command.

set(var "World!")

A variable's value can be accessed using brace expansion, for example if we want to use the message() command to print the string named by var.

set(var "World!")
message("Hello ${var}")
$ cmake -P CMakeLists.txt
Hello World!

Note

cmake -P is called "script mode", it informs CMake this file is not intended to have a project() command. We're not building any software, instead using CMake only as a command interpreter.

Because CMakeLang has only strings, conditionals are entirely by convention of which strings are considered true and which are considered false. These are supposed to be intuitive, "True", "On", "Yes", and (strings representing) non-zero numbers are truthy, while "False" "Off", "No", "0", "Ignore", "NotFound", and the empty string are all considered false.

However, some of the rules are more complex than that, so taking some time to consult the if() documentation on expressions is worthwhile. It's recommended to stick to a single pair for a given context, such as "True"/"False" or "On"/"Off".

As mentioned, lists are strings containing semicolons. The list() command is useful for manipulating these, and many structures within CMake expect to operate with this convention. As an example, we can use the foreach() command to iterate over a list.

set(stooges "Moe;Larry")
list(APPEND stooges "Curly")

message("Stooges contains: ${stooges}")

foreach(stooge IN LISTS stooges)
  message("Hello, ${stooge}")
endforeach()
$ cmake -P CMakeLists.txt
Stooges contains: Moe;Larry;Curly
Hello, Moe
Hello, Larry
Hello, Curly

Exercise 1 - Macros, Functions, and Lists

CMake allows us to craft our own functions and macros. This can be very helpful when constructing lots of similar targets, like tests, for which we will want to call similar sets of commands over and over again. We do so with function() and macro().

macro(MyMacro MacroArgument)
  message("${MacroArgument}\n\t\tFrom Macro")
endmacro()

function(MyFunc FuncArgument)
  MyMacro("${FuncArgument}\n\tFrom Function")
endfunction()

MyFunc("From TopLevel")
$ cmake -P CMakeLists.txt
From TopLevel
      From Function
              From Macro

Like with many languages, the difference between functions and macros is one of scope. In CMakeLang, both function() and macro() can "see" all the variables created in all the frames above them. However, a macro() acts semantically like a text replacement, similar to C/C++ macros, so any side effects the macro creates are visible in their calling context. If we create or change a variable in a macro, the caller will see the change.

function() creates its own variable scope, so side effects are not visible to the caller. In order to propagate changes to the parent which called the function, we must use set(<var> <value> PARENT_SCOPE), which works the same as set() but for variables belonging to the caller's context.

Note

In CMake 3.25, the return(PROPAGATE) option was added, which works the same as set(PARENT_SCOPE) but provides slightly better ergonomics.

While not necessary for this exercise, it bears mentioning that macro() and function() both support variadic arguments via the ARGV variable, a list containing all arguments passed to the command, and the ARGN variable, containing all arguments past the last expected argument.

We're not going to build any targets in this exercise, so instead we'll construct our own version of list(APPEND), which adds a value to a list.

Goal

Implement a macro and a function which append a value to a list, without using the list(APPEND) command.

The desired usage of these commands is as follows:

set(Letters "Alpha;Beta")
MacroAppend(Letters "Gamma")
message("Letters contains: ${Letters}")
$ cmake -P Exercise1.cmake
Letters contains: Alpha;Beta;Gamma

Note

The extension for these exercises is .cmake, that's the standard extension for CMakeLang files when not contained in a CMakeLists.txt

Helpful Resources

Files to Edit

  • Exercise1.cmake

Getting Started

The source code for Exercise1.cmake is provided in the Help/guide/tutorial/Step2 directory. It contains tests to verify the append behavior described above.

Note

You're not expected to handle the case of an empty or undefined list to append to. However, as a bonus, the case is tested if you want to try out your understanding of CMakeLang conditionals.

Complete TODO 1 and TODO 2.

Build and Run

We're going to use script mode to run these exercises. First navigate to the Help/guide/tutorial/Step2 folder then you can run the code with:

cmake -P Exercise1.cmake

The script will report if the commands were implemented correctly.

Solution

This problem relies on an understanding of the mechanisms of CMake variables. CMake variables are names for strings; or put another way, a CMake variable is itself a string which can brace expand into a different string.

This leads to a common pattern in CMake code where functions and macros aren't passed values, but rather, they are passed the names of variables which contain those values. Thus ListVar does not contain the value of the list we need to append to, it contains the name of a list, which contains the value we need to append to.

When expanding the variable with ${ListVar}, we will get the name of the list. If we expand that name with ${${ListVar}}, we will get the values the list contains.

To implement MacroAppend, we need only combine this understanding of ListVar with our knowledge of the set() command.

TODO 1: Click to show/hide answer
TODO 1: Exercise1.cmake
macro(MacroAppend ListVar Value)
  set(${ListVar} "${${ListVar}};${Value}")
endmacro()

We don't need to worry about scope here, because a macro operates in the same scope as its parent.

FuncAppend is almost identical, in fact it could be implemented in the same one liner but with an added PARENT_SCOPE, but the instructions ask us to implement it in terms of MacroAppend.

TODO 2: Click to show/hide answer
TODO 2: Exercise1.cmake
function(FuncAppend ListVar Value)
  MacroAppend(${ListVar} ${Value})
  set(${ListVar} "${${ListVar}}" PARENT_SCOPE)
endfunction()

MacroAppend transforms ListVar for us, but it won't propagate the result to the parent scope. Because this is a function, we need to do so ourselves with set(PARENT_SCOPE).

Exercise 2 - Conditionals and Loops

The two most common flow control elements in any structured programming language are conditionals and their close sibling loops. CMakeLang is no different. As previously mentioned, the truthiness of a given CMake string is a convention established by the if() command.

When given a string, if() will first check if it is one of the known constant values previously discussed. If the string isn't one of those values the command assumes it is a variable, and checks the brace-expanded contents of that variable to determine the result of the conditional.

if(True)
  message("Constant Value: True")
else()
  message("Constant Value: False")
endif()

if(ConditionalValue)
  message("Undefined Variable: True")
else()
  message("Undefined Variable: False")
endif()

set(ConditionalValue True)

if(ConditionalValue)
  message("Defined Variable: True")
else()
  message("Defined Variable: False")
endif()
$ cmake -P ConditionalValue.cmake
Constant Value: True
Undefined Variable: False
Defined Variable: True

Note

This is a good a time as any to discuss quoting in CMake. All objects in CMake are strings, thus the double quote, ", is often unnecessary. CMake knows the object is a string, everything is a string.

However, it is needed in some contexts. Strings containing whitespace require double quotes, else they are treated like lists; CMake will concatenate the elements together with semicolons. The reverse is also true, when brace-expanding lists it is necessary to do so inside quotes if we want to preserve the semicolons. Otherwise CMake will expand the list items into space-separate strings.

A handful of commands, such as if(), recognize the difference between quoted and unquoted strings. if() will only check that the given string represents a variable when the string is unquoted.

Finally, if() provides several useful comparison modes such as STREQUAL for string matching, DEFINED for checking the existence of a variable, and MATCHES for regular expression checks. It also supports the typical logical operators, NOT, AND, and OR.

In addition to conditionals CMake provides two loop structures, while(), which follows the same rules as if() for checking a loop variable, and the more useful foreach(), which iterates over lists of strings and was demonstrated in the Background section.

For this exercise, we're going to use loops and conditionals to solve some simple problems. We'll be using the aforementioned ARGN variable from function() as the list to operate on.

Goal

Loop over a list, and return all the strings containing the string Foo.

Note

Those who read the command documentation will be aware that this is list(FILTER), resist the temptation to use it.

Helpful Resources

Files to Edit

  • Exercise2.cmake

Getting Started

The source code for Exercise2.cmake is provided in the Help/guide/tutorial/Step2 directory. It contains tests to verify the append behavior described above.

Note

You should use the list(APPEND) command this time to collect your final result into a list. The input can be consumed from the ARGN variable of the provided function.

Complete TODO 3.

Build and Run

Navigate to the Help/guide/tutorial/Step2 folder then you can run the code with:

cmake -P Exercise2.cmake

The script will report if the FilterFoo function was implemented correctly.

Solution

We need to do three things, loop over the ARGN list, check if a given item in that list matches "Foo", and if so append it to the OutVar list.

While there are a couple ways we could invoke foreach(), the recommended way is to allow the command to do the variable expansion for us via IN LISTS to access the ARGN list items.

The if() comparison we need is MATCHES which will check if "FOO" exists in the item. All that remains is to append the item to the OutVar list. The trickiest part is remembering that OutVar names a list, it is not the list itself, so we need to access it via ${OutVar}.

TODO 3: Click to show/hide answer
TODO 3: Exercise2.cmake
function(FilterFoo OutVar)

  foreach(item IN LISTS ARGN)
    if(item MATCHES Foo)
      list(APPEND ${OutVar} ${item})
    endif()
  endforeach()

  set(${OutVar} ${${OutVar}} PARENT_SCOPE)
endfunction()

Exercise 3 - Organizing with Include

We have already discussed how to incorporate subdirectories containing their own CMLs with add_subdirectory(). In later steps we will explore the various way CMake code can be packaged and shared across projects.

However for small CMake functions and utilities, it is often beneficial for them to live in their own .cmake files outside the project CMLs and separate from the rest of the build system. This allows for separation of concerns, removing the project-specific elements from the utilities we are using to describe them.

To incorporate these separate .cmake files into our project, we use the include() command. This command immediately begins interpreting the contents of the include()'d file in the scope of the parent CML. It is as if the entire file were being called as a macro.

Traditionally, these kinds of .cmake files live in a folder named "cmake" inside the project root. For this exercise, we'll use the Step2 folder instead.

Goal

Use the functions from Exercises 1 and 2 to build and filter our own list of items.

Helpful Resources

Files to Edit

  • Exercise3.cmake

Getting Started

The source code for Exercise3.cmake is provided in the Help/guide/tutorial/Step2 directory. It contains tests to verify the correct usage of our functions from the previous two exercises.

Note

Actually it reuses tests from Exercise2.cmake, reusable code is good for everyone.

Complete TODO 4 through TODO 7.

Build and Run

Navigate to the Help/guide/tutorial/Step2 folder then you can run the code with:

cmake -P Exercise3.cmake

The script will report if the functions were invoked and composed correctly.

Solution

The include() command will interpret the included file completely, including the tests from the first two exercises. We don't want to run these tests again. Thanks to some forethought, these files check a variable called SKIP_TESTS prior to running their tests, setting this to True will get us the behavior we want.

TODO 4: Click to show/hide answer
TODO 4: Exercise3.cmake
set(SKIP_TESTS True)

Now we're ready to include() the previous exercises to grab their functions.

TODO 5: Click to show/hide answer
TODO 5: Exercise3.cmake
include(Exercise1.cmake)
include(Exercise2.cmake)

Now that FuncAppend is available to us, we can use it to append new elements to the InList.

TODO 6: Click to show/hide answer
TODO 6: Exercise3.cmake
FuncAppend(InList FooBaz)
FuncAppend(InList QuxBaz)

Finally, we can use FilterFoo to filter the full list. The tricky part to remember here is that our FilterFoo wants to operate on list values via ARGN, so we need to expand the InList when we call FilterFoo.

TODO 7: Click to show/hide answer
TODO 7: Exercise3.cmake
FilterFoo(OutList ${InList})