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

WARNING: following is generated by a clanker, though, it is not that bad.

Rule 1: The "Leading Quote" Rule (Format Mode)

If the first non-whitespace character is a double quote ("), the block is interpreted as Format Mode.

  • Context Injection: Automatically prepend ctx-> to every token following the first comma that is a valid C identifier and not followed by a function parenthesis.
  • Example:
    • Input: {{ "%d", user->age }}
    • Output: sb_appendf(sb, "%d", ctx->user->age);

Rule 2: The "Simple Path" Rule (String Mode)

If the content consists entirely of a single C-style "path" (alphanumerics, underscores, ->, or .) and contains no spaces, commas, or semicolons, it is interpreted as String Mode.

  • Transformation: Wrap in sb_appendf(sb, "%s", ctx->[content]);.
  • Example:
    • Input: {{ user->name }}
    • Output: sb_appendf(sb, "%s", ctx->user->name);

Rule 3: The "Statement/Logic" Rule (Raw Mode)

If the content does not meet Rule 1 or Rule 2, or if it contains specific C triggers, it is interpreted as Raw Mode.

A block is Raw Mode if it meets any of these criteria:

  1. It contains a semicolon (;).
  2. It contains curly braces ({ or }).
  3. It starts with a C control-flow keyword (if, for, while, switch, do, else).
  4. It contains an assignment operator (=, +=, etc).

Summary Table for the Preprocessor

If content... Detected Mode Preprocessor Action
Starts with " Format sb_appendf(sb, content);
Is a single word/path (no spaces/punctuation) String sb_appendf(sb, "%s", ctx->content);
Contains ;, {, =, or if/for/while Raw content

Edge Case Handling

1. What if I want to print a local variable (not in ctx)?
Since the "String Mode" rule automatically adds ctx->, you would use Raw Mode syntax to bypass it:

  • Variable in ctx: {{ user->name }} $\rightarrow$ ctx->user->name
  • Local variable: {{ sb_appendf(sb, "%s", my_local); }} (Raw mode because it's a function call).

2. What if I want to print a simple integer without a format string?
A simple identifier like {{ age }} would trigger String Mode and result in "%s", which would crash/warn for an integer.

  • The fix: Use the Format Mode: {{ "%d", age }}. Because it starts with a quote, it correctly triggers Rule 1.

3. Ambiguity with function calls:
{{ get_name() }}

  • This contains parentheses, so it fails the Rule 2 (Simple Path).
  • It does not start with a quote, so it fails Rule 1 (Format).
  • It defaults to Rule 3 (Raw). The user would have to write sb_appendf(sb, "%s", get_name());. This is safe and prevents the preprocessor from making wrong assumptions about return types.

@oduortoni
Copy link

How about:

// Forward declaration
double calculate_score(struct User *user) {
  return user->score * 0.85;
}

char *get_name_tag(struct User *user) {
  static char buffer[256];
  snprintf(buffer, sizeof(buffer), "<span class=\"username\">%s</span>",
           user->name);
  return buffer;
}

// Include the generated template
#define TEMPLATE_CONTEXT struct Context
#define IMPLEMENTATION
#define render_template user_profile_template
#include "user.h" // Generated from user.template.h
#undef render_template

and the template:

    <div>
        <h2>Custom Tag Example</h2>
        {{ "%s", get_name_tag(ctx->user) }}
    </div>
    
    <div>
        <p>Score: {{ calculate_score(ctx->user) * 100 :.0f }}</p>
    </div>

@oduortoni
Copy link

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

{{ get_name() }}

sb_append_fmt(sb, "%s", get_name());

and

{{ user->name }}

sb_append_fmt(sb, "%s", user->name);

Then let C compiler handle the rest. If the function does not exist, i.e was never declared before the template inclusion, then it will err at compile time. Hence no special treatment.

If it is between the {{}} and does not begin with c keywords, then generate the corresponding sb_append_fmt expresion. The compiler will catch the errors. Which leads to the need for line numbers. If an error occurs, we can see the line number within the template itself.

The key words like for and if are copied as is until we get to { then the content in between is treated to the same rules as ordinary tags.

{{ for (int i = 0; i < count; i++) { }}
    <li>{{ items[i] }}</li>
{{ } }}

Becomes:

for (int i = 0; i < count; i++) {
    sb_append(sb, "<li>");
    sb_append_fmt(sb, "%s", items[i]);
    sb_append(sb, "</li>");
}

@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