Finally, Print: Zero-Cost String Interpolation

· 8 min read

Finally, Print

Every programming tutorial starts with “Hello, World!” For Koru, we’ve been building compiler infrastructure for months. Transforms. Continuations. Comptime metaprogramming. All the deep machinery.

Today, we can finally print a formatted string:

const name: []const u8 = "World";
const answer: i32 = 42;

~std.io:print.ln("Hello, ${name:s}! The answer is ${answer:d}.")

Output:

Hello, World! The answer is 42.

This might look simple. It is simple to use. But under the hood, it’s a zero-cost comptime transform that generates inline Zig code with no runtime overhead.


The Old Way Was Painful

Before print.ln, printing a formatted value in Koru required creating a helper event:

// Ugh - just to print an integer?
~event print_int { value: i32 }

~proc print_int {
    std.debug.print("{d}
", .{value});
}

// Now I can use it
| done result |> print_int(value: result.total)

Every type combination needed its own event. Want to print a string and two integers? New event. Want a different format? Another event.

This was technically correct but practically painful.


The New Way

print.ln is a comptime transform that:

  1. Parses the Expression string for \${...} placeholders
  2. Extracts variable names and format specifiers
  3. Generates inline Zig debug.print calls
  4. Resolves variable names at Zig compile time
~std.io:print.ln("User ${name:s} scored ${score:d} points")

Becomes:

@import("std").debug.print("User {s} scored {d} points
", .{name, score});

Zero function call overhead. The format string is a compile-time constant. Zig’s optimizer sees straight through it.


Format Specifiers

The syntax is \${variable:format}:

  • :s - strings ([]const u8)
  • :d - integers and floats
  • :x - hexadecimal
  • :any - any type (default if omitted)
~std.io:print.ln("Pointer: ${ptr:x}, Value: ${val:d}, Name: ${name:s}")

Why explicit specifiers instead of auto-detection? Two reasons:

  1. Universal - Works with Zig variables AND Koru bindings
  2. Transparent - You see exactly what format is used, no magic

Works With Koru Bindings

The real power: print.ln works inside continuation pipelines with Koru-scoped variables:

~capture(expr: { total: @as(i32, 0) })
| as c |> captured { total: 42 }
| captured final |> std.io:print.ln("Captured total: ${final.total:d}")

Output:

Captured total: 42

Notice: no ~ before std.io:print.ln inside the pipeline. The ~ is a leader character that switches from Zig to Koru. Inside a continuation, you’re already in Koru.

The transform:

  1. Runs after ~capture generates const final = c;
  2. Generates inline print code referencing final.total
  3. Zig’s name resolution finds the variable in scope

This works because the generated code references variables by name. Whether they’re Zig const declarations or Koru bindings from | done final |>, Zig finds them.


Pipeline Case Handling

The trickiest part was making print.ln work inside pipelines. When called at top-level:

~std.io:print.ln("Hello")  // Top-level: flow.invocation IS print.ln

The transform sets flow.inline_body and we’re done.

But inside a continuation:

~capture(...)
| captured x |> std.io:print.ln("${x:d}")  // Pipeline: print.ln is a STEP

The flow’s invocation is capture, not print.ln. The transform must:

  1. Detect it’s not the top-level case
  2. Search the continuation tree for print.ln invocations
  3. Replace the step with an inline_code step
  4. Return the modified AST

This is the same pattern ~if and ~for use. The recursive search ensures transforms work at any nesting depth.


What We Learned

  1. Simple features need deep infrastructure - print.ln is trivial to use but required transforms, pipeline handling, and AST manipulation

  2. Explicit beats implicit - Format specifiers are slightly verbose but work universally and transparently

  3. Pipeline handling is essential - Any transform that users might want inside continuations needs both top-level and pipeline case handling

  4. The ~ leader rule matters - ~ switches from Zig to Koru. Inside flows, you’re already in Koru. This confused us once; now there’s a test for it.


What’s Next

  • print.ln.blk - Multi-line Source block interpolation
  • print / print.blk - Same but without trailing newline
  • Better error messages for wrong format specifiers

But for now: we can print formatted strings. After months of building the engine, we can finally say hello to the world.

~std.io:print.ln("Hello, ${world:s}!")

It feels good.