Overview of CMaize’s User API Design

Overview of CMaize’s Design called for CMaize to have a functional-style user API written over top of the object-oriented core. This page describes the design of CMaize’s user API.

What is CMaize’s User API?

To be used as a build system, CMaize will provide interfaces for controlling behavior of the build phases. These interfaces define the user API of CMaize and determine how the user will implement their project’sbuild system.

Why Does CMaize Need a User API?

At a fundamental level, CMaize needs a user API because CMaize will have users who need to be able to interface with CMaize. The more pertinent question is, “Why do we need a functional-style user API modeled after CMake?” To that end, we want to design CMaize’s user API in a manner which simplifies writing the build system for a project. At the same time, we want to facilitate converting existing CMake-based build systems to CMaize-based build systems. Since CMake is a functional language, having a functional-style user API facilitates the conversion.

User API Considerations

cmake-based

Stemming from the cmake-based build system consideration raised as part of the design discussions at Overview of CMaize’s Design, it was decided that the user-facing API of CMaize needed to be written in traditional CMake.

functional style

CMake is a functional-style language. Therefore based on the cmake-based consideration, the user API should adhere to functional-style programming.

CMake to CMaize

Somewhat of a corollary to the cmake-based consideration, user adoption of CMaize is facilitated by having the conversion from an existing CMake-based build system to a CMaize-based build system be as easy as possible.

  • Generally speaking most CMake build systems follow the same flow:

    1. Declare the project’s meta data including name, version, etc.

    2. Declare configuration options

    3. Find the dependencies

    4. Setup the project’s targets

    5. Install the targets

minimize redundancy

One of the motivating considerations for creating CMaize was minimize redundancy. Satisfying this consideration is the job of CMaize’s user API since ultimately any CMaize-based build system will be written using the user API.

package manager

Building/packaging a dependency/project can be a complicated endeavor. From Overview of CMaize’s Design, it has been established that CMaize will have package manager support. In many cases CMaize serves as a unified API for collecting build system data and shuttling it to the package manager. It is thus essential that the user API collects all of the data necessary to drive the package manager.

Proposed User API

../../../_images/user_api.png

Fig. 2 Anticipated control flow of a CMaize-base build system.

This section introduces a high-level overview of CMaize’s user API. The functions comprising the user API are grouped into categories based on the steps presented in consideration CMake to CMaize and shown in Fig. 2. Most of the following subsections are simply summaries of more detailed design discussions (links to those design discussions are provided) and do not explicitly touch on all considerations. This is particularly pertinent in the subsections dealing with declaring and building dependencies and targets.

Project Setup

Following from the cmake-based consideration, the build system the user writes with CMaize should be pure CMake and invoked by running CMake on a CMakeLists.txt file. CMake requires that the first lines of code be:

# Ellipses elide project-specific data and are not part of the API.
cmake_minimum_required(...)
project(...)

The next step is to obtain CMaize. This is done through FetchContent. Since CMaize is not in scope yet, obtaining CMaize must be done with the interfaces provided by traditional CMake and CMaize can not be used to reduce the boilerplate. The code needed to obtain, and load, CMaize is:

include(FetchContent)
FetchContent_Declare(
    cmaize
    GIT_REPOSITORY https://github.com/CMakePP/CMaize
)
FetchContent_MakeAvailable(cmaize)
include(cmaize/cmaize)

At this point we have CMaize loaded and in scope and encourage the user to use CMaize’s APIs as much as possible from this point forward. That said, we note that CMaize will rely on traditional CMake targets, so it is possible to mix and match traditional CMake and CMaize code.

It is worth noting that the include(cmaize/cmaize) line actually initializes CMaize (full details can be found at CMaize Initialization).

Build Options

The next step for most build systems is to define the build process options (beyond those intrinsic to CMake itself). Each option has three parts:

  1. The variable name storing the option’s value.

  2. A description.

  3. A default value.

In traditional CMake, the description is primarily intended for use by CMake’s GUI and the value is restricted to being a boolean. In our experience users typically build CMake programs through the CLI, which makes the description somewhat of a superfluous input; however, we still see value in including it in the API because, one, we need it to call CMake’s option command, and two, it serves as metadata CMaize can leverage (for example to auto-generate build documentation). Allowing options to have values, other than boolean, is useful to avoid having to have a series of options like: enable_vendor0, enable_vendor1, etc. Instead the build system can simply define a single option, say vendor, which can just be set to a string denoting the vendor to enable.

With the above considerations in mind, the proposed CMaize API is:

cmaize_option(enable_feature0 "Feature 0 is used to do something" FALSE)
cmaize_option(target_platform "What GPU type to target?" NVIDIA)

Aside from the function name and the fact cmaize_option accepts values other than booleans, the API is identical to the API CMake uses for its option command. This is by design and stems from the CMake to CMaize consideration.

In addition to cmaize_option, we also propose the cmaize_option_list command for setting multiple options at once. Here the motivation is that some projects need to define many options, which would lead to many calls to cmaize_option. Using cmaize_option_list the above snippet would be:

cmaize_option_list(
   enable_feature0 "Feature 0 is used to do something" FALSE
   target_platform "What GPU type to target?" NVIDIA
)

While this won’t necessarily cut down on the number of lines (we still expect that most build systems will declare one option per line), it is cleaner since it avoids having to repeat cmaize_option on each line. In practice, cmaize_option_list simply wraps looping over “name, description, value” triples and feeding them to cmaize_option.

Find Dependencies

Full discussion: Designing CMaize’s Find or Build Dependency Function.

Configuration settings describe many aspects of a build, including what dependencies are needed. With the configuration options established, the next step of most builds is to find dependencies. While there a plethora of edge cases when it comes to finding dependencies, in most cases CMaize “just” needs to know where to look. CMake already provides mechanisms for users to provide hints for finding packages (e.g. CMAKE_PREFIX_PATH) which CMaize can leverage. The output of finding a dependency is a CMake target which can be consumed by other CMake targets.

If a package is not found, a build system has two options: error out or try to build the package. Modern CMake simplifies the process of building dependencies which also rely on CMake-based build systems (including those using CMaize-based build systems) through CMake’s FetchContent module. While there are many edge cases again, generally CMaize can build the dependency if it knows:

  • where to obtain the dependency from,

  • the target version of the dependency,

  • values for the configuration options, and

  • the package manager to use (if not CMake’s Package Manager).

From these considerations, we propose the following user APIs for finding and building dependencies with CMaize:

# For building a dependency if it can not be found
cmaize_find_or_build_dependency(
   <name>
   URL <where_on_the_internet_to_download_from>
   VERSION <the_version_you_want>
   BUILD_TARGET <target_to_build>
   FIND_TARGET <target_representing_package>
   CMAKE_ARGS <configuration_options_to_set>
)

#Or if the build system wants to insist that a dependency must already exist
cmake_find_dependency(
   <name>
   VERSION <the_version_you_want>
   FIND_TARGET <target_representing_package>
   CMAKE_ARGS <options_it_should_have_been_configured_with>
)

In practice, following from the package manager consideration, these functions are envisioned as wrappers over a package manager. The main goal of the user API is to collect the information needed for the package manager to build the dependency and for CMaize to use the dependency the package manager builds.

Define Build Targets

Full discussion: Designing CMaize’s Add Target Functions.

Once we have found or built all of the project’s dependencies, we can move on to building the build targets. Generally speaking, the information needed to build a target depends on the coding language of the target. For the purposes of this high-level discussion, we focus on C++; build targets for most other coding languages will have similar needs. For a typical C++ target we need to specify the:

  • name of the build target,

  • source files defining the build target’s implementation,

  • header files defining the build target’s public API, and

  • build target’s dependencies (including other build targets).

The proposed CMaize APIs are:

# Declaring a build target for a (C++) library
cmaize_add_library(
   <name>
   SOURCE_DIR <where_the_source_files_are_located>
   INCLUDE_DIRS <directories_containing_header_files>
   DEPENDS <dependency0> <dependency1> ...
)

# Declaring a build target for a (C++) executable is similar
cmaize_add_executable(
   <name>
   SOURCE_DIR <where_the_source_files_are_located>
   INCLUDE_DIRS <directories_containing_header_files>
   DEPENDS <dependency0> <dependency1>
)

Like the “Find Dependencies” step before it, the APIs for defining build targets are designed primarily for collecting information pertaining to the build target. Unlike the “Find Dependencies” step, the backend of API calls for defining build targets is CMake. The results of calling these methods are properly configured CMake targets.

Test Project

After targets are built, the next step is to test that they were built correctly. Testing build targets with CMake often requires:

  • finding dependencies of the testing framework,

  • building testing targets which consume the project’s build targets, and

  • registering the tests with CTest.

It is also worth noting that tests are often built conditionally (e.g., a build system typically does not build the tests of dependencies built during the “Find Dependencies” step). To that end CMake defines the BUILD_TESTING variable; when set to TRUE tests are built, otherwise they are not. Proposed CMaize APIs for testing a project:

cmaize_find_or_build_test_dependency(
    <name>
    URL <where_on_the_internet_to_download_from>
    VERSION <the_version_you_want>
    BUILD_TARGET <target_to_build>
    FIND_TARGET <target_representing_package>
    CMAKE_ARGS <configuration_options_to_set>
)

cmaize_add_test_library(
    <name>
    SOURCE_DIR <where_the_source_files_are_located>
    INCLUDE_DIRS <directories_containing_header_files>
    DEPENDS <dependency0> <dependency1> ...
)

cmaize_add_test_executable(
    <name>
    SOURCE_DIR <where_the_source_files_are_located>
    INCLUDE_DIRS <directories_containing_header_files>
    DEPENDS <dependency0> <dependency1> ...
)

# This is actually CTest's add_test command
add_test(NAME <name> COMMAND)

# This is a convenience function for the common scenario where the
# add_test call simply calls the executable arising from the
# cmaize_add_test_executable
cmaize_add_test(
    <name>
    SOURCE_DIR <where_the_source_files_are_located>
    INCLUDE_DIRS <directories_containing_header_files>
    DEPENDS <dependency0> <dependency1> ...
)

All of the test functions are thin wrappers around the non-test functions of the same name (e.g., cmaize_add_test_library wraps cmaize_add_library), which hide the logic for including the CTest CMake module, and checking that BUILD_TESTING is enabled (if it’s not the functions are no-ops).

Install Project

If the tests are successful (or were skipped), the next step is package installation. Installation typically requires specifying which targets are part of the package, generating the packaging files, and then moving the targets and files to their final location. The main considerations for installing are:

  • Collecting sufficient information to be able to install the package including:

    • where it goes,

    • which pieces get installed, and

    • what the runtime dependencies are.

  • Installation should be done in a manner which considers the package manager.

The proposed installation API is:

cmaize_add_package(
    <name>
    NAMESPACE <namespace>
    TARGETS <target0> <target1> ...
)

Each of the user API calls proceeding cmaize_add_package record the information provided. In turn when it comes time to write the packaging files and install the package, CMaize can do so in a largely automatic manner simply by inspecting the information which was already provided. If the user wants to fine-tune the package installation there are a number of options they can supply including:

  • the namespace to use in the package files (CMake allows prepending a prefix to an installed target’s name to avoid naming collisions), and

  • the specific targets to install (by default only the target with the same name as <name> is installed).

Summary

cmake-based

CMaize’s user API is designed to be invoked directly from the project’s CMakeLists.txt as part of the usual CMake build procedure.

functional style

All user-facing APIs are designed to be functional in nature so as to seamlessly integrate with traditional CMake-based build systems.

CMake to CMaize

Where possible the user-facing CMaize APIs rely on the same keywords and structure as the CMake APIs they wrap. Converting a CMake-based build system to a CMaize-based build system, should therefore almost be a refactoring effort as opposed to a complete rewrite.

minimize redundancy

We have specifically designed the CMaize API to be as succinct as possible by relying on intelligent defaults and recording information. The latter is in particular very important for minimizing redundancy as a lot of CMake’s verbosity comes from having to resupply the same information to many different function calls.

package manager

Most of the user APIs wrap interactions with a package manager. It is the package manager which does the heavy lifting of finding, building, and installing dependencies and/or build build targets.