etch

Compile-Time Evaluation

Most of what your program does happens at runtime. But some computations only need to happen once—when you build. Etch’s comptime feature lets you execute code during compilation, turning runtime work into compile-time constants.

Table of Contents

  1. Why Compile-Time Execution?
  2. Comptime Expressions
  3. Comptime Blocks
  4. Embedding Files
  5. Code Generation
  6. Practical Applications
  7. Best Practices

Why Compile-Time Execution?

Imagine you need to compute factorial(10) in your program. You could calculate it every time the program runs, or you could calculate it once during compilation and embed the result (3,628,800) as a constant. That’s what comptime does.

The benefits:

Everything in a comptime block runs during compilation. The results become constants in your program, with zero runtime overhead.

Comptime Expressions

The simplest use of comptime is wrapping an expression to evaluate it during compilation:

fn factorial(n: int) -> int {
    if n <= 1 { return 1; }
    return n * factorial(n - 1);
}

let value: int = comptime(factorial(10));
print(value);  // Prints 3628800, computed at compile time

The compiler executes factorial(10) during the build, replaces comptime(factorial(10)) with the constant result, and emits code that just prints the number. No recursion at runtime.

What Can Run at Compile Time?

Any pure function—functions that compute a result from their arguments without side effects—can run at compile time:

let sq: int = comptime(square(8));              // 64
let sum: int = comptime(add(10, 20));           // 30
let combo: int = comptime(square(3) + add(5, 2));  // 16

Note: Simple arithmetic like 5 + 3 * 2 is automatically constant-folded by the compiler, so you don’t need comptime for literal expressions. Use it when calling functions.

Comptime Blocks

For multiple statements that should run at compile time, use a comptime { } block:

fn main() {
    comptime {
        print("Building...");
        print("Version 1.0");
    }

    print("Running!");  // Runtime
}

During compilation, you’ll see:

Building...
Version 1.0

When you run the program, you’ll see:

Running!

Compile-Time Variables

Variables in comptime blocks exist only during compilation. They’re not part of the final program:

comptime {
    let build_config = 42;
    print(build_config);  // Prints during build
}
// build_config doesn't exist at runtime

Control Flow at Compile Time

You can use conditionals and loops in comptime blocks to make build-time decisions:

comptime {
    let mode = 1;
    if mode == 1 {
        print("Debug build");
    } else {
        print("Release build");
    }
}

This lets you generate different code based on compile-time conditions.

Embedding Files

One of comptime’s most practical features is embedding file contents directly into your binary. No more shipping configuration files, templates, or assets separately—they become part of the executable.

Basic File Embedding

Use readFile() in a comptime expression:

let config: string = comptime(readFile("config.txt"));
print(config);  // Content is in the binary

The file is read during compilation and its contents become a string constant in your program. At runtime, there’s no file I/O—the data is already there.

Why Embed Files?

Single executable deployment - Ship one file instead of an executable plus data files

Guaranteed availability - The file content can’t go missing or be corrupted

Faster startup - No file system access needed; data is already in memory

Simpler distribution - Users don’t need to maintain file structures

Common Uses

Configuration files:

let defaults: string = comptime(readFile("defaults.json"));

HTML templates:

let page: string = comptime(readFile("template.html"));

Shader code:

let shader: string = comptime(readFile("shader.glsl"));

Code Generation

The most powerful comptime feature is inject(), which lets you dynamically create variables that exist at runtime, based on compile-time logic.

Variable Injection

Create runtime variables from compile-time code:

comptime {
    inject("version", "string", "1.0.0");
    inject("build_num", "int", 42);
}

// Now these variables exist at runtime
print(version);     // "1.0.0"
print(build_num);   // 42

The inject(name, type, value) function takes:

Build Configuration

Combine file reading with injection to generate configuration from external files:

comptime {
    let mode = readFile(".build_mode");

    if mode == "debug" {
        inject("logging_enabled", "int", 1);
        inject("optimization", "int", 0);
    } else {
        inject("logging_enabled", "int", 0);
        inject("optimization", "int", 3);
    }
}

// Use the injected variables
if logging_enabled == 1 {
    initLogging();
}

This pattern lets you generate different code for debug vs release builds without preprocessor macros or build system complexity.

Practical Applications

Pre-Computed Lookup Tables

When you need constant data, compute it once at compile time:

let factorial_10: int = comptime(factorial(10));
let factorials = [
    comptime(factorial(0)),
    comptime(factorial(5)),
    comptime(factorial(10))
];

Perfect for mathematical constants, hash tables, or any data that doesn’t change.

Version and Build Information

Embed version info from external sources:

comptime {
    inject("version", "string", readFile("VERSION"));
    inject("commit", "string", readFile(".git/HEAD"));
}

print("v" + version + " (" + commit + ")");

Feature Flags

Enable or disable features at build time:

comptime {
    let enable_experimental = readFile(".features") == "exp";
    inject("experimental", "int", if enable_experimental { 1 } else { 0 });
}

if experimental == 1 {
    runExperimentalFeature();
}

The disabled code path might not even be compiled into the binary, depending on optimization.

Asset Bundling

Embed all your assets into a single executable:

let logo: string = comptime(readFile("assets/logo.txt"));
let help: string = comptime(readFile("assets/help.txt"));
let license: string = comptime(readFile("LICENSE"));

Ship one file instead of a directory tree.

Best Practices

Use Comptime for Constants

If a value never changes, compute it once at build time:

// ✅ Computed once during compilation
let pi_squared: float = comptime(square(3.14159));

// ❌ Computed every time the program runs
let pi_squared: float = square(3.14159);

Embed Only What You Need

Embedding large files increases binary size. Only embed assets you actually use:

// ✅ Embed essential config
let config: string = comptime(readFile("config.ini"));

// ❌ Don't embed huge data files unnecessarily
let huge_db: string = comptime(readFile("10GB_database.sql"));  // Bad idea!

Document Injected Variables

Since inject() creates variables without explicit declarations, document them:

comptime {
    // Injects: version (string), build_num (int)
    inject("version", "string", readFile("VERSION"));
    inject("build_num", "int", 42);
}

Remember: Comptime Runs During Build

Side effects in comptime blocks happen during compilation, not at runtime:

comptime {
    print("Building...");  // Prints when you compile, not when you run
}

Limitations

What comptime can’t do:

Summary

Compile-time evaluation shifts work from runtime to build time:

Use comptime whenever you can compute something once instead of repeatedly.


Next: Explore Operator Overloading to customize operators for your types.