This tutorial will introduce you to Cakelisp's most unique feature, compile-time code generation. I'm not going to introduce fundamental programming constructs like variables or conditional logic—I'm going to focus on what makes Cakelisp special. This is the most important thing to cover because it is the least familiar to new users from other languages.
First, [[https://macoy.me/code/macoy/cakelisp][download Cakelisp]]. You can also clone it through git via ~git clone https://macoy.me/code/macoy/cakelisp.git~. The source is hosted [[https://macoy.me/code/macoy/cakelisp][here]].
If they succeed, you now have a working ~cakelisp~ binary in the ~bin/~ directory!
** A note on installs
The language is changing fast enough that I recommend against doing a system-wide installation of ~cakelisp~. If you are using version control, you should check in the entirety of Cakelisp as a submodule so that you always have the compatible version for that project.
Let's write a program which takes the name of a command and executes it, much like ~git~ does (e.g. ~git add~ or ~git commit~, where ~add~ and ~commit~ are commands).
However, to show off Cakelisp, we're going to have the following rule:
/Adding a command should be as easy as writing a function./
This means no boilerplate is allowed.
** Taking user input
Modify our ~main~ function to take command-line arguments:
By convention, names are written in Kebab style, e.g. ~num-arguments~ rather than ~numArguments~ or ~num_arguments~. This is purely up to you to follow or ignore, however.
error: execution of a.out returned non-zero exit code 256
#+END_SRC
You can see that Cakelisp ~--execute~ output additional info because we returned a non-zero exit code. This is useful if you are using ~--execute~ in a process chain to run Cakelisp code just like a script.
*TODO*: Currently, Cakelisp ~--execute~ has no way to forward arguments to your output executable. From now on, remove the ~--execute~ and run it like so, adjusting accordingly for your platform (e.g. ~output.exe~ instead of ~a.out~):
#+BEGIN_SRC sh
./bin/cakelisp Hello.cake && ./a.out MyArgument
#+END_SRC
Doing the build on the same command as your execution will make sure that you don't forget to build after making changes.
In order to associate a function with a string input by the user, we need a lookup table. The table will have a string as a key and a function pointer as a value.
However, we need to follow our rule that no human should have to write boilerplate like this, because that would make it more difficult than writing a function.
We will accomplish this by creating a /macro/. Macros in Cakelisp let you execute arbitrary code *at compile time* and generate new tokens for the evaluator to evaluate.
These are unlike C macros, which only do string pasting.
Let's write our first macro:
#+BEGIN_SRC lisp
(defmacro hello-from-macro ()
(tokenize-push output
(fprintf stderr "Hello from macro land!\n"))
(return true))
#+END_SRC
~tokenize-push~ is a generator where the first argument is a token array to output to, and the rest are tokens to output.
We will learn more about it as we go through this tutorial.
Every macro can decide whether it succeeded or failed, which is why we ~(return true)~ to finish the macro. This gives you the chance to perform input validation, which isn't possible in C macros.
This macro now defines a function (~defun~) with name ~command-name~ spliced in for the name token, as well as function arguments and a body.
We now take arguments to the macro, which are defined similarly to function arguments, but do not use C types.
The arguments say ~defcommand~ must take at least three arguments, where the last argument may mark the start of more than three arguments (it will take the rest, hence ~&rest~).
This ~ComptimeHelpers.cake~ file provides a handy macro, ~get-or-create-comptime-var~. We ~import~ it to tell Cakelisp that we need that file to be loaded into the environment.
This allows you to move things around as you like without having to update all the imports. You would otherwise need relative or absolute paths to find files. You only need to add the directory once. The entire Environment and any additional imports will use the same search paths.
Let's create our lookup list. We'll use a C++ ~std::vector~, as it is common in Cakelisp internally and accessible from any macro or generator (*TODO*: This will change once the interface becomes C-compatible):
~defcommand~ is collating a list of command names in ~command-table~. We want to take that table and convert it to a static array for use at runtime.
The problem is we don't know when ~defcommand~ commands are going to finish being defined. We don't know the right time to output the table, because more commands might be discovered during compile-time evaluation.
The solution to this is to use a /compile-time hook/. These hooks are special points in Cakelisp's build procedure where you can insert arbitrary compile-time code.
In this case, we want to use the ~post-references-resolved~ hook. This hook is invoked when Cakelisp runs out of missing references, which are things like an invocation of a macro which hasn't yet been defined.
This hook is the perfect time to add more code for Cakelisp to evaluate.
*It can be executed more than once*. This is because we might add more references that need to be resolved from our hook. Cakelisp will continue to run this phase until the dust settles and no more new code is added.
Each hook has a pre-defined signature, which is what the ~environment~ and other arguments are. If you use the wrong signature, you will get a helpful error saying what the expected signature was.
From our previous note on ~post-references-resolved~ we learned that our hook can be invoked multiple times. Let's store a comptime var to prevent it from being called more than once:
We have to make the decision to do this ourselves because we might actually want a hook to respond to many iterations of ~post-references-resolved~. In this case however, we want it to run only once.
You can see we called ~printFormattedToken~, which is a function available to any compile-time code. It uses a camelCase style to tell you it is defined in C/C++, not Cakelisp.
If all goes well, we should see this output:
#+BEGIN_SRC output
say-your-name
No changes needed for a.out
Hello, Cakelisp!
Hello from macro land!
#+END_SRC
You can see it lists the name /before/ the "No changes needed for a.out" line. This is a sign it is running during compile-time, because the "No changes" line doesn't output until the build system stage.
** It's Tokens all the way down
At this point, we know it's printing successfully, so we have our list. We now need to get this list from compile-time to generated code for runtime.
To do this, we will generate a new array of Tokens and tell Cakelisp to evaluate them, which results in generating the code to define the lookup table.
We need to create the Token array such that it can always be referred back to in case there are errors. We do this by making sure to allocate it on the heap so that it does not go away on function return or scope exit:
We copy ~command-name~ into ~command-name-string~, which copies the contents of ~command-name~ and various other data. We then change the type of ~command-name-string~ to ~TokenType_String~ so that it is parsed and written to have double quotation marks.
The function pointer will actually just be ~command-name~ spliced in, because the name of the command is the same as the function that defines it.
We can use ~tokenize-push~ to create the data needed for each command:
#+BEGIN_SRC lisp
(tokenize-push (deref command-data)
(array (token-splice-addr command-name-string)
(token-splice command-name)))
#+END_SRC
We use ~token-splice-addr~ because ~command-name-string~ is a ~Token~, not a /pointer/ to a ~Token~ like ~command-name~.
Let's output the generated command data to the console to make sure it's good. Here's the full ~create-command-lookup-table~ so far:
We need to define the runtime structure to store the lookup table's data for each command. We also need to define a fixed signature for the commands so that C/C++ knows how to call them.
Add this before ~main~:
#+BEGIN_SRC lisp
;; Our command functions take no arguments and return nothing
Now the runtime knows what the layout of the data is. In ~create-command-lookup-table~, let's generate another array of tokens to hold the runtime lookup table variable:
We have created our code, but we need to find a place to put it relative to the other code in our ~Hello.cake~ module.
This matters because Cakelisp is constrained by declaration/definition order, a constraint imposed by using C/C++ as output languages.
We know we want to use ~command-table~ in ~main~ to run the command indicated by the user-provided argument. That means we need to declare ~command-table~ before ~main~ is defined.
We use a /splice point/ to save a spot to insert code later. Define a splice point right above the ~(defun main~ definition:
Finally, let's evaluate our generated code, outputting it to the splice point. We'll change ~create-command-lookup-table~ to return the result of the evaluation.
You can see it's now as easy to define a command as defining a new function, so we achieved our goal.
We had to do work up-front to generate the code, but that work is amortized over all the time saved each time we add a new command. It also [[https://macoy.me/blog/programming/InterfaceFriction][changes how willing we are to make commands]].
If you are feeling overwhelmed, it's okay. Most languages do not expose you to these types of features.
This tutorial threw you into the deep end of the most advanced Cakelisp feature. This is to showcase the language and to reassure you—If you can understand compile-time code generation, you can understand Cakelisp!
It can take some time to appreciate the power that compile-time code generation and code modification give you. It really is a different way of thinking. Here are some examples where it really was a killer feature:
- [[https://macoy.me/code/macoy/gamelib/src/branch/master/src/ProfilerAutoInstrument.cake][ProfilerAutoInstrument.cake]] automatically instruments every function in the environment, effectively mitigating the big disadvantage of a instrumenting profiler vs. a sampling one (having to do the work to instrument everything)
- [[https://macoy.me/code/macoy/gamelib/src/branch/master/src/Introspection.cake][Introspection.cake]] generates metadata for structs to provide automatic plain-text serialization and a [[https://macoy.me/blog/programming/TypeIntrospection][plethora of other features]]
- [[https://macoy.me/code/macoy/gamelib/src/branch/master/src/TaskSystem.cake][TaskSystem.cake]] allows for a much more [[https://macoy.me/blog/programming/InterfaceFriction][ergonomic interface]] to multi-threaded task systems
- [[https://macoy.me/code/macoy/gamelib/src/branch/master/src/AutoTest.cake][AutoTest.cake]] does very similarly to our ~defcommand~ in order to collect and execute test functions
- [[https://macoy.me/code/macoy/cakelisp/src/branch/master/runtime/HotReloadingCodeModifier.cake][HotReloadingCodeModifier.cake]] converts module-local and global variables into heap-allocated variables automatically, which is an essential step to making hot-reloadable code possible
The ~doc/~ folder contains many files of interest, especially [[file:Cakelisp.org][Cakelisp.org]]. There you will find much more detailed documentation than this tutorial provides.
** Cakelisp self-documentation
Cakelisp provides some features to inspect its built-in generators. From the command line:
#+BEGIN_SRC sh
./bin/cakelisp --list-built-ins
#+END_SRC
...lists all the possible generators built in to Cakelisp. This is especially useful when you forget the exact name of a built-in.
#+BEGIN_SRC sh
./bin/cakelisp --list-built-ins-details
#+END_SRC
This version will list all built-ins as well as provide details for them.
** Reading code
The best way to learn Cakelisp is to read existing code.
There are examples in ~test/~ and ~runtime/~. You can find extensive real-world usage of Cakelisp on [[https://macoy.me/code/macoy][macoy.me]].
[[https://macoy.me/code/macoy/gamelib][GameLib]] is the closest thing to a package manager you will find in Cakelisp land. It provides powerful features as well as easy importing for a number of 3rd-party C and C++ libraries.