cmake -PMost developers know CMake as a build system generator, but CMake is also a full scripting language you can run from the command line with cmake -P script.cmake. No compilers, no build trees, no Makefiles — just a portable, cross-platform scripting engine that ships with every CMake installation. This tutorial teaches you the CMake language from scratch, purely for scripting. You will learn variables, control flow, string and list manipulation, file I/O, process execution, functions, path handling, and the pitfalls that trip up even experienced users. Every concept includes runnable examples you can paste into a .cmake file and execute immediately.
cmake -P does and doesn't doRunning cmake -P myscript.cmake tells CMake to execute a file as a script. No build system is generated, no cache file is created, and no configure step occurs. CMake reads the file top-to-bottom and runs each command, then exits — just like python script.py or bash script.sh.
The invocation syntax is:
cmake [-D<var>=<value>]... -P <script-file> [-- <args>...]Create a file called hello.cmake:
message("Hello from CMake scripting!")Run it:
cmake -P hello.cmakeOutput: Hello from CMake scripting!
Script mode gives you the entire CMake language: variables, conditionals, loops, functions, macros, string and list operations, file I/O, process execution, path manipulation, math, environment variable access, and the include() command for sourcing other .cmake files. You also get find_program(), find_file(), configure_file(), cmake_parse_arguments(), and the platform-detection variables WIN32, UNIX, and APPLE.
Commands that define build targets or actions do not exist in script mode. This includes project(), add_executable(), add_library(), add_custom_target(), add_custom_command(), install(), target_link_libraries(), target_include_directories(), add_subdirectory(), add_test(), and enable_testing(). There is no compiler detection — variables like CMAKE_C_COMPILER or CMAKE_CXX_FLAGS are not set. There is no build directory vs. source directory distinction; CMAKE_SOURCE_DIR, CMAKE_BINARY_DIR, CMAKE_CURRENT_SOURCE_DIR, and CMAKE_CURRENT_BINARY_DIR are all set to the current working directory.
Think of script mode as CMake minus everything related to compiling code. It is, however, surprisingly capable for general-purpose scripting.
set(), and unset()All values in CMake are strings. There are no integers, booleans, or arrays as distinct types. The set() command creates a variable, and unset() removes it.
set(NAME "Alice")
set(COUNT 42) # still a string "42" internally
message("Hello, ${NAME}! Count is ${COUNT}.")Output: Hello, Alice! Count is 42.
The ${VAR} syntax performs variable expansion — it replaces the reference with the variable's value. If a variable is not set, ${VAR} silently expands to an empty string.
message("Undefined: '${DOES_NOT_EXIST}'")
# Output: Undefined: ''To remove a variable, use unset():
set(TEMP "something")
unset(TEMP)
message("TEMP is now: '${TEMP}'") # emptyVariable names are case-sensitive. MyVar, myvar, and MYVAR are three different variables. The convention is UPPER_SNAKE_CASE. Avoid names starting with CMAKE_ or _CMAKE_ — those are reserved.
When you pass multiple values to set(), CMake joins them with semicolons to form a list:
set(FRUITS apple banana cherry)
message("${FRUITS}")
# Output: apple;banana;cherryThis semicolon-separated string is how CMake represents lists. We will cover lists in detail later.
CMake has three types of arguments, and understanding them is essential.
"..."Surrounded by double quotes. Always treated as exactly one argument, regardless of spaces or semicolons inside. Variable references ${VAR} and escape sequences are evaluated.
set(MSG "Hello World")
message("${MSG}") # one argument: Hello World
message("a;b;c") # one argument: a;b;c (semicolons preserved)Not surrounded by any quoting. Variable references and escape sequences are evaluated. Critically, after expansion, the result is split on semicolons into multiple arguments. Spaces also separate unquoted arguments.
set(ITEMS "x;y;z")
message(${ITEMS}) # THREE arguments: x, y, z (concatenated by message: xyz)
message("${ITEMS}") # ONE argument: x;y;z[=[ ... ]=]Surrounded by matching [=[ and ]=] brackets (the number of = signs can vary, including zero). No variable expansion or escape processing occurs. Everything inside is literal. Useful for large blocks of text or embedded code:
message([=[
This is a bracket argument.
No ${expansion} or \n escaping happens here.
Semicolons ; and quotes " are all literal.
]=])Inside quoted and unquoted arguments, these escapes work:
| Sequence | Result |
|---|---|
\\ | Literal backslash |
\" | Literal double quote |
\n | Newline |
\t | Tab |
\r | Carriage return |
\; | Literal semicolon (does not split into list elements) |
"${VAR}"This is the single most important habit to develop in CMake scripting. When you expand a variable without quotes, two things can go wrong.
Problem 1 — empty variables vanish. If VAR is empty or undefined, unquoted ${VAR} produces zero arguments. The argument simply disappears from the command call:
set(VAR "")
# BAD: becomes if(STREQUAL "hello") — wrong number of arguments!
if(${VAR} STREQUAL "hello")
message("match")
endif()
# GOOD: becomes if("" STREQUAL "hello") — correct
if("${VAR}" STREQUAL "hello")
message("match")
endif()Problem 2 — semicolons split into multiple arguments. If VAR contains semicolons, unquoted expansion turns one value into many:
set(PATH "/opt/a;/opt/b")
# Unquoted: message receives two arguments
# Quoted: message receives one argument preserving the semicolonThe rule: always write "${VAR}" unless you intentionally want list expansion. Quoting is free and prevents entire categories of bugs.
message() for outputThe message() command is your primary output tool. Its first argument is an optional mode that controls where output goes and whether execution continues.
message("plain message") # goes to stderr, no prefix
message(STATUS "informational") # goes to stdout, prefixed with "-- "
message(NOTICE "important notice") # goes to stderr, no prefix (same as no mode)
message(WARNING "something looks wrong") # stderr, shown with CMake Warning formatting
message(FATAL_ERROR "cannot continue") # stderr, halts execution immediatelyThe most commonly used modes:
| Mode | Destination | Prefix | Effect |
|---|---|---|---|
(none) / NOTICE | stderr | none | Continues |
STATUS | stdout | -- | Continues |
WARNING | stderr | CMake Warning | Continues |
FATAL_ERROR | stderr | CMake Error | Stops execution |
When you provide multiple string arguments, message() concatenates them with no separator:
message(STATUS "Count: " 42 " items")
# Output: -- Count: 42 itemsFor most scripting, use STATUS for normal output and FATAL_ERROR for unrecoverable errors. You can also use VERBOSE, DEBUG, and TRACE modes whose visibility is controlled by the --log-level command-line flag.
-DYou pass variables to a script using -D flags before the -P flag:
cmake -DNAME=World -DCOUNT=5 -P greet.cmakeInside greet.cmake, NAME and COUNT are normal variables:
message(STATUS "Hello, ${NAME}! Count = ${COUNT}")To pass values with spaces, quote them in the shell:
cmake -DGREETING="Good morning" -P greet.cmakeTo pass a list, use semicolons:
cmake -DFILES="a.txt;b.txt;c.txt" -P process.cmakeImportant: the -D flags must come before -P. Anything after the script filename is treated as a positional argument, not a CMake variable definition.
--You can pass extra arguments after --, accessible via CMAKE_ARGC and CMAKE_ARGV<n>:
cmake -P args.cmake -- hello world# args.cmake
message("Total args: ${CMAKE_ARGC}")
# CMAKE_ARGV0 = path-to-cmake
# CMAKE_ARGV1 = -P
# CMAKE_ARGV2 = args.cmake
# CMAKE_ARGV3 = --
# CMAKE_ARGV4 = hello
# CMAKE_ARGV5 = world
foreach(i RANGE 0 ${CMAKE_ARGC})
if(DEFINED CMAKE_ARGV${i})
message(" ${i}: ${CMAKE_ARGV${i}}")
endif()
endforeach()Note that CMAKE_ARGV<n> includes all command-line arguments: the cmake binary path, -P, the script name, the -- separator, and then your arguments.
Read environment variables with $ENV{NAME} and set them with set(ENV{NAME} value):
message("Home directory: $ENV{HOME}")
message("PATH: $ENV{PATH}")
set(ENV{MY_CUSTOM_VAR} "hello")
message("Custom: $ENV{MY_CUSTOM_VAR}")
unset(ENV{MY_CUSTOM_VAR})Changes to environment variables affect only the running CMake process. They are not written back to your shell and are not inherited by child processes launched via execute_process() unless you take extra steps. You can check whether an environment variable exists with if(DEFINED ENV{NAME}).
Several variables are automatically available when running cmake -P:
| Variable | Value |
|---|---|
CMAKE_SCRIPT_MODE_FILE | Full path of the script file passed to -P. Does not change when include() processes other files. |
CMAKE_CURRENT_LIST_FILE | Full path of the file currently being processed. Changes when include() brings in other files. |
CMAKE_CURRENT_LIST_DIR | Directory containing CMAKE_CURRENT_LIST_FILE. Updates dynamically with includes. |
CMAKE_COMMAND | Absolute path to the cmake executable being used. |
CMAKE_VERSION | CMake version string, e.g. 3.28.1. |
WIN32 | TRUE on Windows |
UNIX | TRUE on Unix-like systems (including macOS) |
APPLE | TRUE on macOS / iOS / other Apple platforms |
CMAKE_ARGC | Number of command-line arguments (script mode only) |
CMAKE_ARGV<n> | Individual command-line arguments by index |
A useful pattern is to locate files relative to the script:
set(DATA_DIR "${CMAKE_CURRENT_LIST_DIR}/data")
file(GLOB inputs "${DATA_DIR}/*.txt")You can detect whether code is running in script mode by checking CMAKE_SCRIPT_MODE_FILE:
if(CMAKE_SCRIPT_MODE_FILE)
message("Running as a script")
endif()if / elseif / else / endifset(X 10)
if(X GREATER 20)
message("big")
elseif(X GREATER 5)
message("medium")
else()
message("small")
endif()
# Output: mediumConditions are not C-style expressions — they use keyword operators. Here are the most useful ones:
Logical operators:
if(NOT condition)
if(cond1 AND cond2)
if(cond1 OR cond2)
if((cond1 OR cond2) AND cond3) # parentheses for groupingTruthiness: if(VAR) is true when VAR is defined and its value is not one of the false constants: 0, OFF, NO, FALSE, N, IGNORE, NOTFOUND, the empty string, or a string ending in -NOTFOUND. Boolean constants are case-insensitive.
Existence tests:
if(DEFINED MY_VAR) # true if variable is set
if(DEFINED ENV{HOME}) # true if environment variable exists
if(EXISTS "/path/to/file") # true if file or directory exists
if(IS_DIRECTORY "/path") # true if path is a directoryNumeric comparisons: EQUAL, LESS, GREATER, LESS_EQUAL, GREATER_EQUAL
String comparisons: STREQUAL, STRLESS, STRGREATER, STRLESS_EQUAL, STRGREATER_EQUAL
Regex matching:
if("hello-world" MATCHES "^hello-(.+)$")
message("Matched: ${CMAKE_MATCH_1}") # world
endif()Captured groups are stored in CMAKE_MATCH_0 (full match) through CMAKE_MATCH_9.
Version comparisons (format major.minor.patch.tweak):
if("${CMAKE_VERSION}" VERSION_GREATER_EQUAL "3.20")
message("Modern CMake")
endif()if() automatic dereferencing trapCMake's if() command was written before the ${VAR} syntax existed. For historical reasons, unquoted argument names are automatically looked up as variables:
set(FOO "YES")
if(FOO) # dereferences FOO → "YES" → true
message("true")
endif()This means if(FOO) and if(${FOO}) do different things. The former checks the variable named FOO. The latter first expands FOO to YES, then checks a variable named YES (double dereferencing — almost never what you want).
Best practice: use if(VAR_NAME) (no ${}) to test truthiness, and if("${VAR}" STREQUAL "value") (quoted, with ${}) for comparisons. Start your scripts with cmake_minimum_required(VERSION 3.5) or later to enable the CMP0054 policy, which prevents quoted strings from being treated as variable names in if().
foreach / endforeachIterate over items:
foreach(fruit apple banana cherry)
message("${fruit}")
endforeach()Iterate over a list variable:
set(COLORS red green blue)
foreach(c IN LISTS COLORS)
message("${c}")
endforeach()The IN LISTS form takes a variable name (not ${COLORS}) and iterates over its elements.
Iterate with IN ITEMS (takes values, not variable names):
foreach(c IN ITEMS ${COLORS} purple)
message("${c}")
endforeach()Range loops:
foreach(i RANGE 5) # 0, 1, 2, 3, 4, 5 (inclusive!)
message("${i}")
endforeach()
foreach(i RANGE 2 8 2) # 2, 4, 6, 8 (start, stop, step)
message("${i}")
endforeach()Note: RANGE is inclusive of both endpoints, unlike most languages.
Zip iteration (CMake 3.17+):
set(NAMES alice bob carol)
set(AGES 30 25 40)
foreach(name age IN ZIP_LISTS NAMES AGES)
message("${name} is ${age}")
endforeach()while / endwhileset(N 5)
while(N GREATER 0)
message("${N}")
math(EXPR N "${N} - 1")
endwhile()Conditions use the same syntax as if().
break(), continue(), return()break() exits the innermost loop. continue() skips to the next iteration. return() exits the current function (or the entire script if called at the top level).
foreach(i RANGE 10)
if(i EQUAL 3)
continue() # skip 3
endif()
if(i EQUAL 7)
break() # stop at 7
endif()
message("${i}")
endforeach()
# Output: 0, 1, 2, 4, 5, 6A CMake "list" is just a string containing semicolons. The string "apple;banana;cherry" is a list of three elements. This fundamental identity drives everything about how lists work.
set(FRUITS apple banana cherry) # same as set(FRUITS "apple;banana;cherry")
list(LENGTH FRUITS count)
message("${count} fruits") # 3 fruitsReading:
list(LENGTH mylist count) # number of elements
list(GET mylist 0 2 -1 result) # elements at indices 0, 2, and last
list(FIND mylist "banana" idx) # index of value, or -1
list(SUBLIST mylist 1 2 result) # 2 elements starting at index 1
list(JOIN mylist ", " result) # join with delimiter: "apple, banana, cherry"Negative indices count from the end: -1 is the last element.
Modifying:
list(APPEND mylist "date") # add to end
list(PREPEND mylist "avocado") # add to front
list(INSERT mylist 2 "blueberry") # insert at index 2
list(REMOVE_ITEM mylist "banana") # remove by value
list(REMOVE_AT mylist 0 3) # remove by index
list(REMOVE_DUPLICATES mylist) # keep first occurrence of each
list(POP_FRONT mylist first second) # remove and capture first elements
list(POP_BACK mylist last) # remove and capture last elementOrdering:
list(REVERSE mylist)
list(SORT mylist) # alphabetical ascending
list(SORT mylist COMPARE NATURAL ORDER DESCENDING CASE INSENSITIVE)Filtering:
set(FILES a.cpp b.h c.cpp d.txt)
list(FILTER FILES INCLUDE REGEX "\\.cpp$") # keeps a.cpp, c.cpp
list(FILTER FILES EXCLUDE REGEX "\\.txt$") # removes .txt filesTransform (apply an action to every element):
set(NAMES alice bob carol)
list(TRANSFORM NAMES TOUPPER) # ALICE;BOB;CAROL
list(TRANSFORM NAMES PREPEND "user_") # user_ALICE;user_BOB;user_CAROL
list(TRANSFORM NAMES REPLACE "^user_" "u/") # regex replace on each elementTransform supports selectors: AT <indices>, FOR <start> <stop> [<step>], REGEX <regex> to apply the action to only matching elements. Use OUTPUT_VARIABLE to store results in a different variable instead of modifying in place.
Because lists are just strings with semicolons, you can accidentally create lists when you meant to build a string, and vice versa:
set(FLAGS "")
# WRONG: creates ";-Wall;-O2" (leading semicolon = empty first element)
list(APPEND FLAGS "-Wall" "-O2")
# ALSO WRONG for space-separated strings like compiler flags:
set(CFLAGS "-O2 -Wall")
list(APPEND CFLAGS "-Werror")
# CFLAGS is now "-O2 -Wall;-Werror" — the semicolon will break thingsFor space-separated strings like compiler flags, use string(APPEND):
string(APPEND CFLAGS " -Werror")Use list() operations only for actual semicolon-separated lists.
The string() command is a Swiss army knife with many sub-commands.
set(S "Hello, World!")
string(LENGTH "${S}" len) # 13
string(SUBSTRING "${S}" 7 5 sub) # "World"
string(SUBSTRING "${S}" 7 -1 rest) # "World!" (-1 = remainder)string(TOUPPER "hello" result) # "HELLO"
string(TOLOWER "HELLO" result) # "hello"
string(STRIP " spaces " result) # "spaces"string(FIND "Hello World" "World" pos) # pos = 6
string(FIND "abcabc" "bc" pos REVERSE) # pos = 4 (last occurrence)
string(REPLACE ".cpp" ".o" result "main.cpp util.cpp")
# result = "main.o util.o"# Match first occurrence
string(REGEX MATCH "[0-9]+" result "version 3.28.1 released")
# result = "3"
# Match all occurrences
string(REGEX MATCHALL "[0-9]+" result "version 3.28.1 released")
# result = "3;28;1" (a list)
# Replace with regex
string(REGEX REPLACE "([0-9]+)\\.([0-9]+)" "\\1_\\2" result "v3.28")
# result = "v3_28"
# Note: \\1, \\2 reference captured groups (double backslash in CMake strings)set(RESULT "")
string(APPEND RESULT "Hello")
string(APPEND RESULT ", " "World!") # "Hello, World!"
# Join multiple strings with a delimiter
string(JOIN "/" result "usr" "local" "bin") # "usr/local/bin"# Repeat a string
string(REPEAT "=-" 20 separator) # "=-=-=-=-=-=-..."
# Timestamp
string(TIMESTAMP now "%Y-%m-%d %H:%M:%S")
message("Current time: ${now}")
# Make valid C identifier
string(MAKE_C_IDENTIFIER "my-header.h" id) # "my_header_h"
# Configure-style substitution
set(NAME "World")
string(CONFIGURE "Hello, @NAME@!" result @ONLY)
# result = "Hello, World!" — replaces @VAR@ references
# Hashing
string(SHA256 hash "some content")
message("Hash: ${hash}")CMake provides integer math via math(EXPR). All values are 64-bit signed integers; floating point is not supported.
math(EXPR result "3 + 4 * 5") # 23 (standard precedence)
math(EXPR result "(3 + 4) * 5") # 35
set(A 100)
set(B 7)
math(EXPR quotient "${A} / ${B}") # 14 (integer division, truncated)
math(EXPR remainder "${A} % ${B}") # 2Supported operators: +, -, *, /, %, | (bitwise OR), & (bitwise AND), ^ (bitwise XOR), ~ (bitwise NOT), << (left shift), >> (right shift), and parentheses for grouping.
Hexadecimal literals work with the 0x prefix, and you can control the output format:
math(EXPR result "0xFF + 1") # 256
math(EXPR result "255" OUTPUT_FORMAT HEXADECIMAL) # "0xff"The file() command provides portable file I/O.
# Read entire file into a variable
file(READ "config.txt" content)
# Read file as a list of lines
file(STRINGS "config.txt" lines)
foreach(line IN LISTS lines)
message("${line}")
endforeach()
# Read lines matching a pattern
file(STRINGS "config.txt" matches REGEX "^#")
# Get file size
file(SIZE "data.bin" size)
message("File size: ${size} bytes")
# Compute hash
file(SHA256 "data.bin" hash)
message("SHA-256: ${hash}")file(STRINGS) reads a file line-by-line, strips binary content, and stores the result as a CMake list (one element per line). This is often more useful than file(READ) for text processing.
file(WRITE "output.txt" "Line 1\n") # creates/overwrites
file(APPEND "output.txt" "Line 2\n") # appendsMultiple content arguments are concatenated:
file(WRITE "report.txt" "Name: " "${NAME}" "\nDate: " "${DATE}" "\n")file(GLOB txt_files "*.txt") # all .txt files in current dir
file(GLOB_RECURSE all_cmake "*.cmake") # recursive search
# Relative paths
file(GLOB sources RELATIVE "${CMAKE_CURRENT_LIST_DIR}" "src/*.cpp")file(MAKE_DIRECTORY "output/subdir") # creates directory and parents
file(RENAME "old.txt" "new.txt") # atomic rename/move
file(REMOVE "temp.txt" "temp2.txt") # delete files (no error if missing)
file(REMOVE_RECURSE "build_dir") # delete directory tree
# Copy files
file(COPY "src/config.ini" "src/data.json"
DESTINATION "${CMAKE_CURRENT_LIST_DIR}/output")
# Get file modification time
file(TIMESTAMP "myfile.txt" mod_time "%Y-%m-%d %H:%M:%S")file(DOWNLOAD "https://example.com/data.json" "data.json"
STATUS download_status
TIMEOUT 30
SHOW_PROGRESS)
list(GET download_status 0 status_code)
if(NOT status_code EQUAL 0)
list(GET download_status 1 error_msg)
message(FATAL_ERROR "Download failed: ${error_msg}")
endif()The STATUS variable receives a list of two elements: a numeric return code (0 = success) and an error string.
execute_process() runs external commands. It launches processes immediately (not at build time), making it perfect for scripts.
execute_process(
COMMAND git log --oneline -5
WORKING_DIRECTORY "${CMAKE_CURRENT_LIST_DIR}"
OUTPUT_VARIABLE git_log
ERROR_VARIABLE git_err
RESULT_VARIABLE git_result
OUTPUT_STRIP_TRAILING_WHITESPACE
)
if(NOT git_result EQUAL 0)
message(FATAL_ERROR "git failed: ${git_err}")
endif()
message("Recent commits:\n${git_log}")Key options:
| Option | Purpose |
|---|---|
COMMAND | The program and its arguments |
WORKING_DIRECTORY | CWD for the process |
OUTPUT_VARIABLE | Capture stdout |
ERROR_VARIABLE | Capture stderr |
RESULT_VARIABLE | Exit code (0 = success) or error string |
OUTPUT_STRIP_TRAILING_WHITESPACE | Remove trailing whitespace from captured output |
TIMEOUT | Kill process after N seconds |
INPUT_FILE / OUTPUT_FILE / ERROR_FILE | Redirect stdin/stdout/stderr to files |
COMMAND_ERROR_IS_FATAL ANY | Automatically fatal-error on non-zero exit |
No shell is involved — arguments are passed directly to the program. Shell operators like >, |, and && are treated as literal arguments. Use INPUT_FILE/OUTPUT_FILE for redirection. For piping, use multiple COMMAND clauses, which create a pipeline:
execute_process(
COMMAND grep "error" log.txt
COMMAND wc -l
OUTPUT_VARIABLE error_count
OUTPUT_STRIP_TRAILING_WHITESPACE
)Multiple COMMAND keywords in a single execute_process() call run concurrently as a pipeline (stdout of each piped to stdin of the next). For sequential execution, use separate execute_process() calls.
Portable commands with cmake -E: The CMake executable itself provides cross-platform utilities like copy, make_directory, remove, tar, touch, md5sum, and echo. Use ${CMAKE_COMMAND} to reference the current cmake binary:
execute_process(COMMAND "${CMAKE_COMMAND}" -E make_directory "output")
execute_process(COMMAND "${CMAKE_COMMAND}" -E copy "src.txt" "dest.txt")A function creates a new variable scope. Variables set inside a function are local — they do not affect the caller.
function(greet name)
message("Hello, ${name}!")
set(INTERNAL "secret")
endfunction()
greet("Alice") # Hello, Alice!
message("${INTERNAL}") # empty — INTERNAL is not visible hereFunction names are case-insensitive when called, but the convention is lowercase.
Returning values from a function requires PARENT_SCOPE:
function(add_numbers a b result_var)
math(EXPR sum "${a} + ${b}")
set(${result_var} "${sum}" PARENT_SCOPE)
endfunction()
add_numbers(3 4 ANSWER)
message("3 + 4 = ${ANSWER}") # 3 + 4 = 7The pattern is: the caller passes a variable name as an argument, and the function uses set(${result_var} value PARENT_SCOPE) to write the result into that variable in the caller's scope.
Critical detail: PARENT_SCOPE sets the variable only in the parent scope — not locally. If you need the value both locally and in the parent, set it twice:
function(compute out_var)
set(result "42")
set(${out_var} "${result}" PARENT_SCOPE) # parent
# result is still "42" locally, but ${out_var} itself is not set locally
endfunction()With CMake 3.25+, you can use return(PROPAGATE var1 var2) as a cleaner alternative that sets variables in the parent scope and returns in one step.
Inside a function (or macro), these variables are available:
| Variable | Meaning |
|---|---|
ARGC | Number of arguments passed |
ARGV | All arguments as a list |
ARGN | Arguments beyond the named parameters (extra args) |
ARGV0, ARGV1, ... | Individual arguments by index |
function(print_all first)
message("First: ${first}")
message("Total args: ${ARGC}")
message("Extra args: ${ARGN}")
foreach(arg IN LISTS ARGN)
message(" Extra: ${arg}")
endforeach()
endfunction()
print_all("A" "B" "C" "D")
# First: A
# Total args: 4
# Extra args: B;C;D
# Extra: B
# Extra: C
# Extra: DA macro looks like a function, but does not create a new scope. It executes as if its body were pasted in place at the call site:
macro(set_version major minor)
set(VERSION "${major}.${minor}")
endmacro()
set_version(3 14)
message("${VERSION}") # 3.14 — VERSION is set in the caller's scopeIn a macro, ARGC, ARGV, ARGN, and ARGV0/ARGV1/... are not real CMake variables — they are string replacements (like C preprocessor macros). This means you cannot use them with commands that expect variable names:
macro(bad_example)
# WRONG: ARGN is not a real variable in a macro
foreach(arg IN LISTS ARGN)
message("${arg}")
endforeach()
endmacro()
# Workaround: copy to a real variable first
macro(good_example)
set(_args "${ARGN}")
foreach(arg IN LISTS _args)
message("${arg}")
endforeach()
endmacro()Another macro gotcha: return() inside a macro does not return from the macro — it returns from the calling scope (the function or script that invoked the macro). This is almost never what you want.
Rule of thumb: prefer function() over macro(). Use macros only for very small utilities where you intentionally want to modify the caller's variables without the PARENT_SCOPE ceremony.
cmake_path()Added in CMake 3.20, cmake_path() performs pure syntactic path manipulation — no filesystem access. It always uses forward slashes and follows the same conventions as C++'s std::filesystem::path.
Important: cmake_path() takes a variable name, not a value. You pass the name of a variable containing a path.
set(mypath "/home/user/projects/app/src/main.cpp")
cmake_path(GET mypath FILENAME name) # "main.cpp"
cmake_path(GET mypath EXTENSION ext) # ".cpp"
cmake_path(GET mypath STEM stem) # "main"
cmake_path(GET mypath PARENT_PATH parent) # "/home/user/projects/app/src"set(base "/usr/local")
cmake_path(APPEND base "lib" "cmake") # base = "/usr/local/lib/cmake"
# SET with automatic native path conversion (backslashes → forward slashes)
cmake_path(SET win_path "C:\\Users\\Alice\\Documents")
# win_path = "C:/Users/Alice/Documents"
# Normalize: collapse . and .. and redundant separators
set(messy "/home/user/./projects/../projects/app//src")
cmake_path(NORMAL_PATH messy)
# messy = "/home/user/projects/app/src"cmake_path(IS_ABSOLUTE mypath result) # TRUE or FALSE
cmake_path(IS_RELATIVE mypath result)
cmake_path(HAS_EXTENSION mypath result)
cmake_path(IS_PREFIX "/usr" "/usr/local/bin" NORMALIZE result) # TRUEcmake_path(NATIVE_PATH mypath native)
# On Windows: native = "C:\Users\..."
# On Unix: native = "/home/user/..."
cmake_path(CONVERT "C:\\path\\to\\file" TO_CMAKE_PATH_LIST result)
# result = "C:/path/to/file"For CMake versions before 3.20, use get_filename_component() or file(TO_CMAKE_PATH) as older alternatives for path operations.
The include() command sources another .cmake file in the current scope (no new scope is created, unlike function()):
# helpers.cmake
function(log msg)
string(TIMESTAMP ts "%H:%M:%S")
message(STATUS "[${ts}] ${msg}")
endfunction()# main.cmake
include("${CMAKE_CURRENT_LIST_DIR}/helpers.cmake")
log("Script started")
log("Doing work...")Variables set in the included file are visible in the including file. CMAKE_CURRENT_LIST_FILE and CMAKE_CURRENT_LIST_DIR update to reflect the included file during its processing, then restore when it finishes.
Useful options:
include(helpers OPTIONAL) # no error if not found
include(helpers OPTIONAL RESULT_VARIABLE found) # found = full path or NOTFOUNDWhen no path is given (just a name like helpers), CMake searches CMAKE_MODULE_PATH and then its built-in module directory for helpers.cmake. You can also include built-in CMake modules:
include(CMakePrintHelpers) # provides cmake_print_variables()
cmake_print_variables(CMAKE_VERSION CMAKE_COMMAND)This script reads a template, substitutes placeholders, and writes the result:
# process_template.cmake
# Usage: cmake -DINPUT=template.txt -DOUTPUT=result.txt
# -DPROJECT_NAME=MyApp -DVERSION=2.1.0 -P process_template.cmake
cmake_minimum_required(VERSION 3.20)
if(NOT DEFINED INPUT OR NOT DEFINED OUTPUT)
message(FATAL_ERROR
"Usage: cmake -DINPUT=<file> -DOUTPUT=<file> "
"-DPROJECT_NAME=<name> -DVERSION=<ver> -P process_template.cmake")
endif()
# Read the template
file(READ "${INPUT}" content)
# Substitute placeholders
string(REPLACE "@PROJECT_NAME@" "${PROJECT_NAME}" content "${content}")
string(REPLACE "@VERSION@" "${VERSION}" content "${content}")
string(TIMESTAMP build_date "%Y-%m-%d")
string(REPLACE "@BUILD_DATE@" "${build_date}" content "${content}")
# Write result
file(WRITE "${OUTPUT}" "${content}")
file(SIZE "${OUTPUT}" out_size)
message(STATUS "Wrote ${OUTPUT} (${out_size} bytes)")# run_tests.cmake
# Usage: cmake -DBUILD_DIR=build -P run_tests.cmake
cmake_minimum_required(VERSION 3.20)
if(NOT DEFINED BUILD_DIR)
set(BUILD_DIR "${CMAKE_CURRENT_LIST_DIR}/build")
endif()
# Build the project
message(STATUS "Building...")
execute_process(
COMMAND "${CMAKE_COMMAND}" --build "${BUILD_DIR}" --config Release
RESULT_VARIABLE rc
)
if(NOT rc EQUAL 0)
message(FATAL_ERROR "Build failed (exit code ${rc})")
endif()
# Run tests
message(STATUS "Running tests...")
execute_process(
COMMAND "${CMAKE_COMMAND}" -E chdir "${BUILD_DIR}"
ctest --output-on-failure -C Release
RESULT_VARIABLE rc
)
if(NOT rc EQUAL 0)
message(FATAL_ERROR "Tests failed (exit code ${rc})")
endif()
message(STATUS "All tests passed.")# deploy.cmake
# Usage: cmake -DSRC=dist -DDEST=/opt/myapp -P deploy.cmake
cmake_minimum_required(VERSION 3.20)
if(NOT DEFINED SRC OR NOT DEFINED DEST)
message(FATAL_ERROR "Usage: cmake -DSRC=<dir> -DDEST=<dir> -P deploy.cmake")
endif()
# Discover files
file(GLOB_RECURSE all_files RELATIVE "${SRC}" "${SRC}/*")
list(LENGTH all_files total)
message(STATUS "Deploying ${total} files from ${SRC} to ${DEST}")
file(MAKE_DIRECTORY "${DEST}")
set(copied 0)
set(skipped 0)
foreach(f IN LISTS all_files)
set(src_path "${SRC}/${f}")
set(dst_path "${DEST}/${f}")
# Get parent directory and ensure it exists
cmake_path(GET dst_path PARENT_PATH dst_dir)
file(MAKE_DIRECTORY "${dst_dir}")
# Copy only if source is newer
if(EXISTS "${dst_path}")
if("${src_path}" IS_NEWER_THAN "${dst_path}")
file(COPY_FILE "${src_path}" "${dst_path}")
math(EXPR copied "${copied} + 1")
else()
math(EXPR skipped "${skipped} + 1")
endif()
else()
file(COPY_FILE "${src_path}" "${dst_path}")
math(EXPR copied "${copied} + 1")
endif()
endforeach()
message(STATUS "Done: ${copied} copied, ${skipped} up-to-date.")These are the mistakes that cause the most debugging pain. Each one is easy to avoid once you know about it.
Always quote variable expansions. Write "${VAR}", not ${VAR}, in almost all contexts. Without quotes, empty variables vanish (breaking if() argument counts) and semicolons silently split values into multiple arguments. The only time to leave quotes off is when you deliberately want list expansion, such as foreach(item ${MY_LIST}).
Don't double-dereference in if(). Write if(MY_VAR) to test whether a variable is set and truthy. Writing if(${MY_VAR}) first expands MY_VAR and then tries to dereference the result as another variable name — a confusing double lookup. For string comparisons, use if("${MY_VAR}" STREQUAL "value").
Lists are strings, strings are lists. There is no separate list type. A semicolon in any string makes it a multi-element list. This means list(APPEND) on a space-separated string like compiler flags will embed a semicolon that breaks downstream tools. Use string(APPEND) for space-separated strings; reserve list() for actual semicolon-delimited lists.
set() with PARENT_SCOPE does not set locally. After set(X "val" PARENT_SCOPE), the variable X is unchanged in the current scope. If you need it both locally and in the parent, call set() twice — once without and once with PARENT_SCOPE. In CMake 3.25+, use return(PROPAGATE X) instead.
Macros leak everything. A macro runs in the caller's scope. Variables you set inside a macro persist after it returns. Macro arguments (ARGC, ARGV, ARGN) are string substitutions, not real variables, so foreach(x IN LISTS ARGN) inside a macro looks up ARGN as a variable name in the caller's scope — not the macro's arguments. Prefer functions unless you have a specific reason to modify the caller's scope.
Nested list flattening. set(X a "b;c") produces a;b;c — a flat list of three elements, not a nested structure. CMake lists do not support elements containing semicolons (without workarounds). If you need structured data, consider using variable-name conventions like MAP_key instead of trying to nest lists.
Start scripts with cmake_minimum_required(). Even in -P mode, this sets CMake policies that control language behavior. Without it, you may get legacy behaviors like if() dereferencing quoted strings (CMP0054) or if() not recognizing numeric constants properly (CMP0012). Use cmake_minimum_required(VERSION 3.16) or later as a sensible baseline.
CMake's scripting mode is a capable cross-platform alternative to Bash or Python for build-adjacent tasks. You invoke it with cmake -P script.cmake, pass data via -D flags, and get access to the full CMake language minus build-target commands. The language has real depth — string() alone has over a dozen sub-commands, file() can read, write, glob, hash, download, and copy, and execute_process() can run any external program with full I/O capture.
The three concepts that unlock productive CMake scripting are: everything is a string (including lists, which are semicolon-delimited strings), always quote "${VAR}" (preventing the two most common bug categories), and functions create scope, macros don't (which determines where you use each). Start with cmake_minimum_required(VERSION 3.20) at the top of every script, use cmake_path() for portable path handling, and lean on ${CMAKE_COMMAND} -E for cross-platform shell operations. With these foundations, you can write deployment scripts, test harnesses, file processors, and automation tools that run identically on Windows, macOS, and Linux — anywhere CMake is installed.