Skip to content

Instantly share code, notes, and snippets.

@stryku
Last active August 17, 2022 04:10
Show Gist options
  • Save stryku/4c69aa510711c9da6705fa4df4545515 to your computer and use it in GitHub Desktop.
Save stryku/4c69aa510711c9da6705fa4df4545515 to your computer and use it in GitHub Desktop.

Declarative extensions in CMakeSL

Teaser

A hello world executable created using declarative CMakeSL format.

Table of contents

Disclaimer

None of what you'll find in this article is part of official CMake project. It's part of CMakeSL project.

The declarative format implementation is more of a POC of integration between two paradigms. All the concepts can change. A lot of key concepts (like conditional compilation etc.) are not implemented yet.

Why

Declarative extensions were inspired mostly by this comment from Craig Scott. To summarize, declarative files as leaf nodes are easy to parse and modify by tools. That means, writing a functionality that adds a source file to your project, as well as to CMakeLists is a piece of cake.

Ok, maybe not THAT easy, but still much easier than interpreting and modifing an imperative context.

What about "using one paradigm in the project"

You got me. Yes, I used that argument while releasing CMakeSL to the world. But, the Craig's comment convinced me to introduce declarative extensions.

Imperative -> declarative

Why I call it 'declarative extensions'? That's because the declarative files are meant to be the leaf nodes of your CMakeLists files. There is actually one rule: When you enter a declarative file, you can NOT call/add/execute/take information from a file written in imperative CMakeSL. On the other hand, in an imperative file you can add_subdirectory() with a declarative file or create an executable using add_declarative_file() function.

So, you can extent imperative files with declarative ones.

Declarative format

The declarative format is inspired by Qbs (which uses a QML dialect). But, the format was not copied in 100%. I decided to tweak it a little to make some of its parts similar to analogous parts from the imperative format. E.g. in Qbs to assign a value to a property, you use a colon: name: "hello_world". In declarative CMakeSL you use and equal: name = "hello_world". In Qbs to 'derive' a component from another one, you use its name as the name of the component and you name the file as your shiny new component name. So, if you'd want to create a reusable component MyReusableStaticLibrary, you create a file MyReusableStaticLibrary.qbs where you define your component, using StaticLibrary as a root node:

StaticLibrary {
    ...
}

Then import it in client file and use it with MyReusableStaticLibrary name.

To create a reusable component in CMakeSL, you need to declare it using component keyword and derive it from the builtin static_library component:

component MyReusableStaticLibrary : static_library {
    // Some private dependencies that every library should link to.
    dependencies.private = [ "some", "private", "deps" ]
};

There are other differences which will be covered later.

Paradigms integration

Technically, using declarative files is an alternative way of creating targets.

add_declarative_file() and getting stuff from it

(Check out a complete example: add_declarative_file)

If you call add_declarative_file("some_file.dcmsl") function, the function returns an object. The type of the object is unspecified. It depends on how the component was declared. If the type of the component derives from a static_library, then you get an object of some type that derives from a cmake::library type. If the type of the component derives from an executable, then you get an object of some type that derives from a cmake::executable type. You get the point. You can then pass this object to e.g. cmake::executable::link_to() method. For example, you have a declarative file "foo.dcmsl" that introduces a library:

static_library {
    name = "foo"
    files.public = [ "foo.cpp" ]
}

in your CMakeLists.cmsl you add this declarative file and link the executable with the returned library:

int main()
{
    cmake::executable exe = ...;
    auto foo = add_declarative_file("foo.dcmsl");
    exe.link_to(foo);
}

Note that, the cmake::executable::link_to() method takes a cmake::library as a parameter. The add_declarative_file("foo.dcmsl") call returns an object of unspecified type. The type depends on the declarative file content. In our case, the object is convertible to cmake::library, so the right overload of cmake::executable::link_to is found and chosen.

add_subdirectory() with CMakeLists.dcmsl

(Check out a complete example: add_subdirectory_declarative)

It's a common case to have a directory that introduces a single target and does nothing more. If you don't need to do any magic there, a single declarative file is a good solution.

Let's imagine a simple project with one executable and one library. Root CMakeLists creates an executable and adds_subdirectory with the library that later on is linked to the executable. The library is introduced using declarative format.

An example folder structure:

├── CMakeLists.cmsl
├── main.cpp
└── some_lib
    ├── CMakeLists.dcmsl
    └── lib.cpp

CMakeLists.cmsl:

int main()
{
    ...

    project p = project("Some Application");

    auto some_lib = add_subdirectory("some_lib");

    auto exe = p.add_executable("Application", {"main.cpp"} );
    exe.link_to(some_lib);

    ...
}

And the some_lib/CMakeLists.dcmsl:

static_library {
    name = "some_lib"
    files.public = [
        "lib.cpp"
    ]
}

That's it. No other CMakeLists has to be written to introduce the library.

Declarative root file

(Check out a complete example: declarative_root_cmakelists)

Declarative file can be used as the root CMakeLists. So, if you don't need to do anything fancy in the project, or just want to quickly check something out, that's the way.

Brief introduction

Builtin components

There are four builtin component types that you should be aware of:

  • static_library
  • shared_library
  • executable
  • test_executable

All of them derive from product component type. You can find available properties in the docs.

forwarding_lists properties

You've probably spotted a usage of property like files.public. At this point you probably figured out what does it mean.

In CMake a lot of properties can be PUBLIC, PRIVATE or INTERFACE. You can add directories to an include path of a target, with PRIVATE keyword. That means they won't be forwarded.

The same functionality comes with properties of product component, that are of forwarding_lists type. forwarding_lists has three properties:

  • public
  • private
  • interface

and you can access them and assign to them like you saw earlier, e.g.:

static_library {
    name = "foo"

    files.public = [
        "foo.cpp"
    ]

    include_dirs.public = [
        "public/include/dir"
    ]

    inculde_dirs.private = [
        "private/include/dir"
    ]

    dependencies.interface = [
        "bar_dependency"
    ]
}

And so on..

Custom components

(Check out a complete example: custom_component)

(Check out a real world example in CMakeSL itself: test_utils, and usage e.g.: test/decl_ast/CMakeLists.dcmsl)

There are of course cases, when you would want to create a custom component. E.g. you'd want to add a suffix to all of your libraries. You could, of course, add the suffix manually in every static_library declaration, but that's ugly. Instead, you can create a custom component that accumulates common functionality and properties. Later on it can be used to declare stuff.

Let's say that we want all of our libraries to have suffix "_my_fancy_lib" and include directory "my/fancy/dir". Let's declare a custom component out of it, that derives from the builtin static_library component:

component my_fancy_lib : static_library {
    name_suffix = "_my_fancy_lib"

    include_dirs.public = [
        "my/fancy/dir"
    ]
}

And now it can be used to declare our fancy lib:

my_fancy_lib {
    name = "foo"

    include_dirs.public += [
        "another/fancy/dir"
    ]
}

Mind the += while adding include directory. Thanks to that, the list with another/fancy/dir will be appended to the list in component declaration. If you'd use a plain =, the list would be overriden.

Modules

(Check out a complete example: module_import)

Imperative CMakeSL as well as the declarative one has modules support. With the example above, you most likely would want to declare my_fancy_lib component in a commonly accessible file and use it wherever you need.

Just create the file, let's name it my_fancy_lib.dcmsl and in import it the file in whchich you want to use it:

import "my_fancy_lib.dcmsl";

my_fancy_lib {
    name = "foo"

    include_dirs.public += [
        "another/fancy/dir"
    ]
}

Accessing old-style CMake variables

(Check out a complete example: cmake_variables_accessor)

In a lot of cases you'll need to get a value of an old-style CMake variable. You can get it from an accessor named cmake_variables. CMakeSL is statically typed, so you need to provide information how the variable should be treated, using as_bool, as_int, as_double, as_string or as_list.

For example, let's say that you have a root CMakeLists written in the vanilla CMake: CMakeLists.txt:

cmake_minimum_required(VERSION 3.14.3)

project("MyFancyProject")

set(MY_FANCY_SUFFIX "_my_fancy_suffix")

add_subdirectory(my_fancy_lib)

In the my_fancy_lib dir, you declare a library that gets its name suffix from the accessor: my_fancy_lib/CMakeLists.dcmsl:

static_library {
    name = "my_fancy_lib"
    name_suffix = cmake_variables.MY_FANCY_SUFFIX.as_string

    ...
}

Real world usage

CMakeSL using imperative and declarative format is able to build itself. Check out its files, e.g.:

Thanks for reading

As always, I'm curious what you think about all this.

(BTW, there is a discussion about introducing a declarative language in CMake, so if you like the idea and have something to say, check out the issue)

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