Skip to content

Instantly share code, notes, and snippets.

@ckwastra
Last active May 16, 2025 14:00
Show Gist options
  • Save ckwastra/9b1468d7f6db12d013edfd91b716e194 to your computer and use it in GitHub Desktop.
Save ckwastra/9b1468d7f6db12d013edfd91b716e194 to your computer and use it in GitHub Desktop.

Implementing assert in CMake

Assertions are powerful tools for verifying function preconditions and postconditions, catching bugs early in development, and ultimately leading to more robust code. Many popular programming languages include built-in support for assertions. Unfortunately, CMake does not currently provide a built-in assert command. While it's arguable that CMake's if command can already cover much of this functionality, a dedicated assert command would be a valuable addition. It offers a more concise and expressive syntax, improving both readability and intent. This post explores several approaches to implementing assert in the CMake language and discusses why it would be best implemented as a built-in CMake command.

The Goal

Our goal is to implement an assert command that can be used by other CMake code. This command should accept the same arguments as the existing if command. It should raise an error if the arguments are invalid or if the evaluated condition is false. In such cases, an appropriate error message associated with the failed condition should be printed to standard error. In simple terms, we want to perfectly forward the arguments from assert to if, with suitable negation applied.

My First (and Naive) Attempt

Off the top of my head, assert could be implemented like this:

# assert([<argument>...])
function(assert)
  if(NOT (${ARGV}))
    message(FATAL_ERROR "Assertion failed: ${ARGV}")
  endif()
endfunction()

Quite simple, right? :) It uses the predefined ARGV variable to collect and expand the original arguments passed to the function. We can test it with:

set(x 0)
set(y 1)
assert(x EQUAL y) # Assertion failed: x;EQUAL;y

It works—simple yet effective! However, as you might have guessed, this is not the end of our journey. This approach has two serious drawbacks.

Nested Lists Should Not Be Flattened

If one of the arguments contains an unescaped semicolon (;), it will be interpreted as a list separator when ${ARGV} is expanded. This results in the arguments being split unintentionally, leading to incorrect behavior in the inner if command. For example:

set(x "0;1")
assert(x STREQUAL "0;1") 

The code above produces the following error:

if given arguments:
  "NOT" "(" "x" "STREQUAL" "0" "1" ")"
Unknown arguments specified

As you can see, 0 and 1 were passed to if as separate arguments. This is problematic—it transforms innocent-looking code into something completely different, and often malformed at best. Fortunately, for functions, the PARSE_ARGV mode of the cmake_parse_arguments command can help. It reads and parses the ARGV# variables corresponding to each argument. After matching any keywords (of which we define none here), the remaining arguments are collected into the <prefix>_UNPARSED_ARGUMENTS variable, with semicolons properly escaped. This prevents the earlier issue:

# assert([<argument>...])
function(assert)
  cmake_parse_arguments(PARSE_ARGV 0 arg "" "" "")
  if(NOT (${arg_UNPARSED_ARGUMENTS}))
    message(FATAL_ERROR "Assertion failed: ${arg_UNPARSED_ARGUMENTS}")
  endif()
endfunction()

set(x "0;1")
assert(NOT x STREQUAL "0;1") # Assertion failed: NOT;x;STREQUAL;0\;1

Now, we've resolved one issue—but we still have one more problem to tackle.

Empty Arguments Must Be Preserved

Another issue is that empty arguments ("" or [[]]) are dropped when the arguments are forwarded. For example:

set(x "")
assert(x STREQUAL "") 

This fails with the following error:

if given arguments:
  "NOT" "(" "x" "STREQUAL" ")"
Unknown arguments specified

The crucial "" is missing in the if command. In this case, after variable expansion, ${arg_UNPARSED_ARGUMENTS} becomes x;STREQUAL;, and CMake treats the final semicolon as a redundant list separator. As a result, it recognizes only two arguments: x and STREQUAL. This issue also occurs if an empty string appears at the beginning or in the middle of the argument list—ultimately causing us to lose empty arguments entirely. To address this, we need to introduce an additional layer of argument forwarding that preserves empty values explicitly.

Using cmake_language to Evaluate the Code

Both of the issues we encountered above are also discussed in Craig Scott's post: Forwarding Command Arguments in CMake. In the comments of that post, Craig shared the following snippet:

function(outer)
    cmake_parse_arguments(PARSE_ARGV 0 FWD "" "" "")
    set(quotedArgs "")
    foreach(arg IN LISTS FWD_UNPARSED_ARGUMENTS)
        string(APPEND quotedArgs " [===[${arg}]===]")
    endforeach()
    cmake_language(EVAL CODE "inner(${quotedArgs})")
endfunction()

The idea is this: instead of calling inner directly with unquoted, expanded arguments, we reconstruct the argument list with proper quoting ourselves and use cmake_language(EVAL CODE) to evaluate the resulting code. Clever, right? Let's apply this to our assert command:

# assert([<argument>...])
function(assert)
  cmake_parse_arguments(PARSE_ARGV 0 arg "" "" "")
  set(condition "")
  foreach(arg IN LISTS arg_UNPARSED_ARGUMENTS)
    string(APPEND condition " [==[${arg}]==]")
  endforeach()
  cmake_language(EVAL CODE "
      if(NOT (${condition}))
        message(FATAL_ERROR [===[Assertion failed: ${condition}]===])
      endif()")
endfunction()

However, while this approach works for many commands, it doesn't work reliably for if without additional modifications. The reason is that if is special in CMake: it is extremely sensitive to whether its arguments are quoted or not. Quoting all arguments indiscriminately changes their meaning, often breaking the condition entirely. In short, simply wrapping all arguments in quotes is not a viable solution when dealing with if.

Operators Must Not Be Quoted

Operators used in the if command—such as STREQUAL, EQUAL, and even parentheses (( and ))—are only recognized by CMake when unquoted. This behavior is documented in the CMP0054 policy. Consider the following code:

set(x "")
assert(x STREQUAL "") 

It results in the following error:

if given arguments:
  "NOT" "(" "x" "STREQUAL" "" ")"
Unknown arguments specified

Here, the empty string "" is preserved, but the operators are not recognized because they were quoted. To work around this, we can maintain a list of known operators. If an argument matches one of these operators, we forward it unquoted; otherwise, we quote it. This leads to a modified implementation:

# assert([<argument>...])
function(assert)
  cmake_parse_arguments(PARSE_ARGV 0 arg "" "" "")
  set(condition "")
  set(operators
      "(" ")" COMMAND POLICY TARGET TEST EXISTS IS_READABLE IS_WRITABLE
      IS_EXECUTABLE IS_DIRECTORY IS_SYMLINK IS_ABSOLUTE DEFINED EQUAL
      LESS LESS_EQUAL GREATER GREATER_EQUAL STREQUAL STRLESS
      STRLESS_EQUAL STRGREATER STRGREATER_EQUAL VERSION_EQUAL
      VERSION_LESS VERSION_LESS_EQUAL VERSION_GREATER
      VERSION_GREATER_EQUAL PATH_EQUAL IN_LIST IS_NEWER_THAN MATCHES NOT
      AND OR)
  foreach(arg IN LISTS arg_UNPARSED_ARGUMENTS)
    if(arg IN_LIST operators)
      string(APPEND condition " ${arg}")
    else()
      string(APPEND condition " [==[${arg}]==]")
    endif()
  endforeach()
  cmake_language(EVAL CODE "
      if(NOT (${condition}))
        message(FATAL_ERROR [===[Assertion failed: ${condition}]===])
      endif()")
endfunction()

assert(NOT "" STREQUAL "") # Assertion failed: NOT [==[]==] STREQUAL [==[]==]

However, this doesn't fully solve the problem. It introduces a new issue: even if a user intentionally passes an operator as a quoted string (e.g., to compare string content), it will still be interpreted as an operator, not an operand. This may be an acceptable limitation if we never need to use operands that coincidentally match operator names. Also, this approach still fails for conditions like x STREQUAL "" where x is a variable. The issue again relates to how quoting affects evaluation.

The <variable|string> Parameter Depends on Quoting

The if command in CMake has a special behavior when handling parameters marked as <variable|string>. This means it accepts both variable names and string literals as arguments. Whether it treats an argument as a variable or a string depends on whether the argument is quoted.

  • If the argument is quoted, it's treated as a literal string.
  • If the argument is unquoted, CMake checks whether a variable with that name exists. If so, it uses the variable's value; if not, it falls back to treating it as a string.

This behavior is explained in more detail in Craig Scott's post: Quoting in CMake. Because of this, our current assert implementation does not work correctly in the following scenario:

set(x "")
assert(x STREQUAL "") # Assertion failed: [==[x]==] STREQUAL [==[]==]

Here, x is forwarded as a quoted string ("x"), which changes the meaning of the condition. The final expression becomes "x" STREQUAL "", which always evaluates to false—regardless of the actual value of x. A workaround is to require users to manually expand variables when needed. That is, instead of writing:

assert(x STREQUAL "")

users should write:

assert("${x}" STREQUAL "")

However, these two forms are not always equivalent. The forms if(<variable>) and if(<string>) have similar but subtly different semantics. For example:

set(x "x")
if(x)
  assert("${x}") # Assertion failed: [==[x]==]
endif()
  • The if(<variable>) form checks whether the variable's value matches a true constant (e.g., 1, ON, YES).
  • The if(<string>) form checks whether the string matches a false constant (e.g., 0, OFF, NO).

For values that match neither, the two forms may produce opposite results. That said, such tests are arguably questionable—users should prefer using explicit comparisons like STREQUAL for clarity and robustness. If we truly want to support both quoting behaviors, we could introduce two separate functions: one that quotes its arguments (assert_quoted) and one that leaves them unquoted (assert_unquoted). But given the complexity already involved, it may not be worth pursuing this further unless a strong need arises.

Note

Did you notice a subtle deficiency in the technique of forwarding arguments using arg_UNPARSED_ARGUMENTS? Consider the following:

assert() # Assertion failed:
assert("") # Assertion failed:

Both assertions fail, as expected. However, if you inspect the error messages closely, you'll see that the "" in assert("") has been silently dropped. Although the final result is the same in both cases, the inconsistency is undesirable and suggests the implementation isn't robust. Here's what happens internally:

  • For assert(), the variable arg_UNPARSED_ARGUMENTS is simply not defined.
  • For assert(""), it is defined but empty (i.e., contains a single empty string).

Unfortunately, from the perspective of the foreach loop, both scenarios behave the same—no iterations occur. As a result, the empty string is not preserved in the error message. To address this, we need to manually loop over the arguments using the ARGC and ARGV# variables, which reliably capture all passed arguments, including empty ones. Here's the updated implementation:

# assert([<argument>...])
function(assert)
  set(condition "")
  set(operators
      "(" ")" COMMAND POLICY TARGET TEST EXISTS IS_READABLE IS_WRITABLE
      IS_EXECUTABLE IS_DIRECTORY IS_SYMLINK IS_ABSOLUTE DEFINED EQUAL
      LESS LESS_EQUAL GREATER GREATER_EQUAL STREQUAL STRLESS
      STRLESS_EQUAL STRGREATER STRGREATER_EQUAL VERSION_EQUAL
      VERSION_LESS VERSION_LESS_EQUAL VERSION_GREATER
      VERSION_GREATER_EQUAL PATH_EQUAL IN_LIST IS_NEWER_THAN MATCHES NOT
      AND OR)
  if("${ARGC}" GREATER "0")
    math(EXPR stop "${ARGC} - 1")
    foreach(i RANGE 0 "${stop}")
      set(arg "${ARGV${i}}")
      if(arg IN_LIST operators)
        string(APPEND condition " ${arg}")
      else()
        string(APPEND condition " [==[${arg}]==]")
      endif()
    endforeach()
  endif()
  cmake_language(EVAL CODE "
      if(NOT (${condition}))
        message(FATAL_ERROR [===[Assertion failed: ${condition}]===])
      endif()")
endfunction()

assert()   # Assertion failed:
assert("") # Assertion failed: [==[]==]

Pass the Entire Condition as a String

The root cause of the previous issues lies in the fact that within the assert function, we lose the quoting information of the arguments. So, instead of processing the arguments one by one, why not allow the user to pass the entire condition as a single argument? That is:

# assert(<condition>)
function(assert condition)
  cmake_language(EVAL CODE "
      if(NOT (${condition}))
        message(FATAL_ERROR [==[Assertion failed: ${condition}]==])
      endif()")
endfunction()

set(x 42)
assert([[x EQUAL "42"]]) # (1)
assert([["${x}" EQUAL "42"]]) # (2)
assert("[[${x}]] EQUAL [[42]]") # (3)

This approach avoids the problem of preserving quotes around individual arguments, but it comes at the cost of making the syntax more cumbersome and unintuitive. Note that in the code above:

  • In cases (1) and (2), the variable x is expanded inside the assert function.
  • In case (3), the variable is expanded immediately at the call site.

While this distinction is not particularly important in this context, it becomes critical in later considerations.

function is Not the Best Fit for assert

In CMake, function parameters become real variables (unlike macros, where parameters are simply placeholders for string replacements). This behavior can lead to issues in some edge cases. For example:

set(condition "something")
assert([[condition STREQUAL "something"]]) # Assertion failed: condition STREQUAL "something"
assert([["${condition}" STREQUAL "something"]]) # Assertion failed: "${condition}" STREQUAL "something"
assert("[[${condition}]] STREQUAL [[something]]") 

The first two assertions fail because, inside the assert function, the variable condition holds a different value (i.e., the condition string that was passed). The workaround for this issue is to expand the variables as early as possible, as in the third assertion. This also applies to other function-associated variables, such as ARGC and CMAKE_CURRENT_FUNCTION. However, some cases still cannot be resolved. For example, the assertion assert(DEFINED ARGV) will always pass for the same reason. Thus, it turns out that a CMake function is not ideal for implementing assert. But what about using macros?

macro is Not Ideal, Either

A macro implementation is essentially the same as the function-based one described above:

# assert(<condition>)
macro(assert condition)
  cmake_language(EVAL CODE "
      if(NOT (${condition}))
        message(FATAL_ERROR [==[Assertion failed: ${condition}]==])
      endif()")
endmacro()

set(condition "something")
assert([[condition STREQUAL "something"]])
assert([["${condition}" STREQUAL "something"]])
assert("[[${condition}]] STREQUAL [[something]]")

In this case, all three assertions pass because there is no actual condition variable defined inside the macro. Before the commands in assert are executed, the presence of ${condition} will be replaced with the corresponding arguments. So far, so good. However, this kind of string replacement introduces some subtle behaviors. Since ${condition} is replaced with the condition string upon entry, when CMake parses the arguments of the cmake_language command, the condition is parsed once again. This means there is a second round of variable reference processing and escape sequence handling. For example:

set(x [[\\]])
set(y [[${x}]])
assert([=[x STREQUAL [[\\]]]=]) # Assertion failed: x STREQUAL [[\]]
assert([=[y STREQUAL [[${x}]]]=]) # Assertion failed: y STREQUAL [[\\]]

As we can see, even though the arguments are correctly bracketed, ${x} is still expanded, and \\ turns into \. This occurs because the arguments are processed before the cmake_language command is called, which means the inner if command is still subject to the second round of parsing. I have not found a way to disable this two-phase argument processing for macros. To safely use this macro, we must avoid variable references and escape sequences in the final condition as much as possible. Alternatively, we could add extra backslashes, but this is both ugly and error-prone:

assert([=[x STREQUAL [[\\\\]]]=])
assert([=[y STREQUAL [[\${x}]]]=])

Note

Named placeholders and ARGV# placeholders are not the same in macros. Consider the following example:

set(ARGC 2)
assert([["${ARGC}" EQUAL "2"]]) # Assertion failed: "1" EQUAL "2"

This fails in the above implementation because CMake replaces strings in macros in the following order: named placeholders, ${ARGC}, ${ARGN}, ${ARGV}, and finally various ${ARGV#}. In this case, ${condition} is replaced first, and then ${ARGC} inside it is replaced with 1 (the number of arguments passed to assert). To fix this, based on the processing order, we need to use ${ARGV0} instead of a named placeholder. The corrected implementation looks like this:

# assert(<condition>)
macro(assert)
  cmake_language(EVAL CODE "
      if(NOT (${ARGV0}))
        message(FATAL_ERROR [==[Assertion failed: ${ARGV0}]==])
      endif()")
endmacro()

set(ARGC 2)
assert([["${ARGC}" EQUAL "2"]])

Closing Thoughts

Robustly and correctly forwarding arguments in CMake is challenging, and it is even harder for our anticipated assert command. In this post, we explored several ways to achieve our goal of implementing a correct and robust assert command. The unfortunate truth is that none of these methods is ideal; each comes with its own drawbacks. In the end, we may simply opt to use the plain if command instead, as it is simpler and more straightforward than any of the complex implementations discussed above. If you happen to know of any better solutions, please feel free to share them in the comments :). Ultimately, a built-in assert command would be the best choice, as it would also have the opportunity to display richer and more detailed messages in the event of a failed assertion.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment