Skip to content

Instantly share code, notes, and snippets.

@koutoftimer
Last active February 8, 2026 07:43
Show Gist options
  • Select an option

  • Save koutoftimer/79427d8127f42f3829de77e5f86dfb83 to your computer and use it in GitHub Desktop.

Select an option

Save koutoftimer/79427d8127f42f3829de77e5f86dfb83 to your computer and use it in GitHub Desktop.
Minimal templating in C

What is it?

Proof of concept.

We can create arbitrary templates like index.template.h and translate them with ctrans into valid C code that we can later call as shown in run.c

$ make
gcc ctrans.c -o ctrans
./ctrans index.template.h > index.h
gcc -I. run.c
./a.out
. . first line         >end
astart  end
\tsecond line    <<
 end
\tsecond line    <<
 end
\tsecond line    <<
 end
\tsecond line    <<
 end
\tsecond line    <<
 end
\tsecond line    <<
 end
\tsecond line    <<
 end
\tsecond line    <<
 end
\tsecond line    <<
 end
\tsecond line    <<
John Doe
// initially generated with https://aistudio.google.com/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>
#include <sys/stat.h>
char const* filename = NULL;
void print(char c) {
if (c == '\"') printf("\\\"");
else if (c == '\r') printf("\\r");
else if (c == '\n') printf("\\n");
else if (c == '\\') printf("\\\\");
else printf("%c", c);
}
void process(char const* text) {
char const* cursor = text;
char const* start_tag;
while ((start_tag = strstr(cursor, "{{")) != NULL) {
// Print text before tag
if (start_tag != cursor) {
printf(" sb_append(sb, \"");
while (cursor < start_tag) {
print(*cursor++);
}
printf("\");\n");
}
// Find end of tag
const char* end_tag = strstr(start_tag, "}}");
if (!end_tag) break;
// Extract and print code
printf(" ");
const char* code_ptr = start_tag + 2;
while (code_ptr < end_tag) {
printf("%c", *code_ptr++);
}
cursor = end_tag + 2;
}
// Print remaining line content
if (*cursor != '\0') {
printf(" sb_append(sb, \"");
while (*cursor) {
print(*cursor++);
}
printf("\");\n");
}
}
int main(int argc, char** argv) {
FILE* in;
struct stat st;
char *buffer;
if (argc == 2) {
filename = argv[1];
in = fopen(filename, "r");
if (!in) {
perror("Can't open specified file");
return EXIT_FAILURE;
}
if (fstat(fileno(in), &st)) {
perror("Can't get file stat");
return EXIT_FAILURE;
}
buffer = malloc(st.st_size + 1);
if (fread(buffer, 1, st.st_size, in) == 0) {
perror("Can't read specified file");
return EXIT_FAILURE;
};
buffer[st.st_size] = '\0';
}
printf("#include <string-builder.h>\n\n");
printf("struct Context;\n\n");
printf("void render_template(struct Context* ctx, struct StringBuilder* sb);\n\n");
printf("#ifdef IMPLEMENTATION\n\n");
printf("void render_template(struct Context* ctx, struct StringBuilder* sb) {\n");
process(buffer);
printf("}\n\n");
printf("#endif // IMPLEMENTATION\n");
free(buffer);
return EXIT_SUCCESS;
}
. . first line >end
astart {{ for (int i = 0; i < 10; ++i) { }} end
\tsecond line <<
{{ } sb_append(sb, ctx->user->name); }}
run: a.out
./a.out
ctrans: ctrans.c
gcc ctrans.c -o ctrans
index.h: index.template.h ctrans
./ctrans index.template.h > index.h
a.out: index.h
gcc -I. run.c
#include <stdio.h>
struct User {
char const* name;
};
struct Context {
struct User *user;
};
#define IMPLEMENTATION
#define render_template index_page_template
#include "index.h"
#undef render_template
int main() {
struct StringBuilder sb = {0};
struct User user = {"John Doe"};
struct Context ctx = {&user};
index_page_template(&ctx, &sb);
sb.data[sb.len] = '\0';
puts(sb.data);
}
#ifndef _STRING_BUILDER_H_
#define _STRING_BUILDER_H_
struct StringBuilder;
void sb_finalize(struct StringBuilder* sb);
#define PRINTF_LIKE __attribute__((format(printf, 2, 3)))
PRINTF_LIKE void sb_appendf(struct StringBuilder* sb, char const* fmt, ...);
#define sb_append(sb, text) sb_appendf((sb), "%s", (text))
#ifdef IMPLEMENTATION
#include <assert.h>
#include <stdarg.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
struct StringBuilder {
char data[1024 * 1024];
size_t len;
};
void
sb_finalize(struct StringBuilder* sb)
{
assert(sb->len < sizeof(sb->data) && "Too small internal buffer");
sb->data[sb->len] = '\0';
}
void
sb_appendf(struct StringBuilder* sb, char const* fmt, ...)
{
va_list args;
va_start(args, fmt);
size_t free_space = sizeof(sb->data) - sb->len - 1;
sb->len += vsnprintf(sb->data + sb->len, free_space, fmt, args);
va_end(args);
}
#endif // _STRING_BUILDER_H_
@koutoftimer
Copy link
Author

#define TEMPLATE_CONTEXT struct Context

Why? Just let user define its own struct Context and use it directly why do we need TEMPLATE_CONTEXT?

Forward declaration can be done in the raw mode. GCC allows nested functions.

@oduortoni
Copy link

Yes Forward declaration can be done in the raw mode, but maybe it is a function that is also used in other places. Then the fact that C allows for both is an advantage.

@koutoftimer
Copy link
Author

@oduortoni

Essentially, we generate the same for each. No special treatment:

If you can implement it (preferably in C to lower dependencies list) - go ahead, otherwise I'd stick with the rules hallucinated by aistudio. I like those rules because they are simple and easy to implement. They enforce quite a bit of constrains but, I have never considered to reimplement Jinja2 in the first place.

@oduortoni
Copy link

I have just been extending the code within your gist while trying different ideas. The code samples I showed were part of these trials. It is just a collection of simple scripts that try to parse one specific idea. When I am sure they wont break, I can combine them into one cohesive codebase and share them.

@koutoftimer
Copy link
Author

@oduortoni it is important to let user omit the line if it consists of the single statement that ends with -}} or :}}. Without this rule, in order to produce

<ul>
    <li>1</li>
    <li>2</li>
    <li>3</li>
</ul>

we need

<ul>{{ for (int i = 1; i <= 3; ++i) { }}
    <li>{{ sb_appendf(sb, "%d", i); }}</li>
{{ } }}</ul>

which is, kind of, fine, in this case, but gets unbearably ugly very fast.

With it, it looks nice and clean

<ul>
{{ for (int i = 1; i <= 3; ++i) { -}}
    <li>{{ sb_appendf(sb, "%d", i); }}</li>
{{ } -}}
</ul>

@koutoftimer
Copy link
Author

And, if it is fast enough, we can even make custom LSP plugin that translates template on a fly and feeds into clangd. This way it is not only fully type checked template system but also with dev tools enabled.

@koutoftimer
Copy link
Author

koutoftimer commented Feb 7, 2026

I got an idea that makes template statements more explicit and clear.

Let's just replace each occurance of $ symbol with ctx->

This way we have {{ $user->name }} translated into {{ ctx->user->name }} and it enables to do plain

<ul>
{{ for (int i = 0; i < $posts->len; ++i) { -}}
    <li>{{ "%d: %s", i+1, $posts->title[i] }}</li>
{{ } -}}
</ul>

@oduortoni
Copy link

  1. I get your idea on the stray newlines. Mine also produces this:
    <!-- Loop -->
    <ul>
    
        <li>Alice</li>
    
        <li>Bob</li>
    
        <li>Charlie</li>
    
    </ul>
    
    <!-- Complex expression -->
    <p>Score: 85</p>

With spaces within the

  • . So, yeah, we will go with your idea.

    1. Why do you hate ctx so much. I actually like that it makes it explicit that the user comes from the context and avoids "magic". It reads, "there is a struct called ctx that has a user which has a name". But if it is that much of a problem, I guess we could think of the dollar sign. Just needed to say that I like having the ctx there.
  • @koutoftimer
    Copy link
    Author

    #define $ ctx->

    should do the trick

    @koutoftimer
    Copy link
    Author

    @oduortoni

    Why do you hate ctx so much.

    I'm implying that you are following context injection described earlier. Which means you can't just write <li>{{ "%d", i }}</li> because it will inject ctx->, that is why explicit context injection is way simpler and can be reused anywhere, even in for loop.

    @oduortoni
    Copy link

    Awesome. That way, anyone can pick their poison: explicit {{ ctx->user->name }} vs {{ $user->name }} and both will still work. While {{ "%d", i }} does not get translated to the wrong version with context pre-pended. I am fine with that.

    @koutoftimer
    Copy link
    Author

    @oduortoni I just realized that template inheritance is not a hard task, it is not a task at all.

        <!-- Include translated templates using function composition -->
        {{ render_footer_template(ctx, sb); }}

    and then

    #define IMPLEMENTATION
    #define render_template render_footer_page
    #include "footer.h"
    #define render_template render_index_page
    #include "index.h"
    // ...
    render_index_page(&ctx, &sb);

    @oduortoni
    Copy link

    oduortoni commented Feb 7, 2026

    We have a problem. I was trying to work with the:

    #define $ ctx->
    

    But apparently, we forgot that a $ is not a valid variable name in C. So the preprocessor wont catch it.

    As a result, we wont be relying on the preprocessor for that replacement. We will have to do it manually.

    @koutoftimer
    Copy link
    Author

    @oduortoni Nope, we can, but we have to place a whitespace in between

    $ cat run.c 
    #include <stdio.h>
    
    #define $(...) ctx->__VA_ARGS__
    #define $$ ctx->
    
    struct Context {
      int value;
    };
    
    int main() {
      struct Context context = {.value = 100500};
      struct Context *ctx = &context;
      printf("context value: %d\n", ctx->value);
      printf("context value: %d\n", $(value));
      printf("context value: %d\n", $$ value);
    }
    
    $ gcc -E -P run.c | tail
    struct Context {
      int value;
    };
    int main() {
      struct Context context = {.value = 100500};
      struct Context *ctx = &context;
      printf("context value: %d\n", ctx->value);
      printf("context value: %d\n", ctx->value);
      printf("context value: %d\n", ctx-> value);
    }
    
    $ gcc -std=gnu23 run.c && ./a.out 
    context value: 100500
    context value: 100500
    context value: 100500
    

    @koutoftimer
    Copy link
    Author

    Or, even

    #define $ (*ctx)
    // ...
    $.user.name

    Yeah, this way you can't miss space because it will be totally different identifier.

    @oduortoni
    Copy link

    oduortoni commented Feb 7, 2026

    It feels like we are fighting the pre-processor at this point. Its just one function to do the replacement. What if I forget the whitespace? And I hate to have a language where a whitespace is interpreted differently; python aside. Again, are moving away from the -> to the . as in $.user.name ?

    @koutoftimer
    Copy link
    Author

    koutoftimer commented Feb 7, 2026

    And we can do

    #define  _(...) sb_appendf(sb, __VA_ARGS__)
    #define __(...) sb_appendf(sb, "%s", __VA_ARGS__)
    // ...
    {{ __($.user.name) }}
    {{  _("%d", $.user.age) }}

    I do not like magic but this is something you'll have to do over and over again and typing entire C program to output a digit is not good either. It would be nice to come up with something like std::sstream sb to do {{ sb << $.user.age }} or shortcut {{<< $.user.age }} to avoid wrapping the entire statement into parentecies.

    @koutoftimer
    Copy link
    Author

    Aaaand, if we are going to do $. this is the simplest rule possible for appendf(sb, "%s", __VA_ARGS__);

    @oduortoni
    Copy link

    Or we could just give special meaning to the $ within the template. If it is inside the {{}}, it is the context and we replace it. It is a simple string replacement. No more magic. And no differentiating between underscores and dundersocres.

    At this point it does seem as though, you introduced a problem because you did not like the ctx->, and now the whole implementation is being complicated based off a problem that should not even exist in the first place.

    @koutoftimer
    Copy link
    Author

    @koutoftimer
    Copy link
    Author

    The drawback is that the string mode can't output local variable char *text as a string. And I'm very willing to accept this small inconvenience in the favor of epic simplicity.

    @oduortoni
    Copy link

    The drawback is that the string mode can't output local variable char *text as a string. And I'm very willing to accept this small inconvenience in the favor of epic simplicity.

    Drawback of what exactly? The complex macro or the simple $ sign string replacement.

    @koutoftimer
    Copy link
    Author

    koutoftimer commented Feb 7, 2026

    @oduortoni

    The complex macro or the simple $ sign string replacement.

    #define $ (*ctx) is not a complex macro. It is a macro that enforces specific
    syntax. Difference between $user.name and $.user.name is barely noticeable,
    yet later is completely valid C code that simplifies the logic of translation
    tool.

    Drawback of what exactly?

    {{ for (int i = 0; i < $.user.friends.len; ++i) { }}
        {{ char *name = get_friend_name($.user.friends.ids[i]); }}
        <li>{{ "%s", name }}</li>
    {{ } }}

    In situation like this, you can't use String mode because it doens't start
    with the $. so you have to use Format mode.

    @koutoftimer
    Copy link
    Author

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