Compiled temapltes.

Benchmark cleanup
This commit is contained in:
2026-01-22 11:10:47 +05:30
parent 714db30a8c
commit 654b45ee10
31 changed files with 2168 additions and 1251 deletions

172
src/benchmarks/bench.zig Normal file
View File

@@ -0,0 +1,172 @@
//! Pugz Benchmark - Compiled Templates vs Pug.js
//!
//! Both Pugz and Pug.js benchmarks read from the same files:
//! src/benchmarks/templates/*.pug (templates)
//! src/benchmarks/templates/*.json (data)
//!
//! Run Pugz: zig build bench-all-compiled
//! Run Pug.js: cd src/benchmarks/pugjs && npm install && npm run bench
const std = @import("std");
const tpls = @import("tpls");
const iterations: usize = 2000;
const templates_dir = "src/benchmarks/templates";
// ═══════════════════════════════════════════════════════════════════════════
// Data structures matching JSON files
// ═══════════════════════════════════════════════════════════════════════════
const SubFriend = struct {
id: i64,
name: []const u8,
};
const Friend = struct {
name: []const u8,
balance: []const u8,
age: i64,
address: []const u8,
picture: []const u8,
company: []const u8,
email: []const u8,
emailHref: []const u8,
about: []const u8,
tags: []const []const u8,
friends: []const SubFriend,
};
const Account = struct {
balance: i64,
balanceFormatted: []const u8,
status: []const u8,
negative: bool,
};
const Project = struct {
name: []const u8,
url: []const u8,
description: []const u8,
};
const SearchRecord = struct {
imgUrl: []const u8,
viewItemUrl: []const u8,
title: []const u8,
description: []const u8,
featured: bool,
sizes: ?[]const []const u8,
};
// ═══════════════════════════════════════════════════════════════════════════
// Main
// ═══════════════════════════════════════════════════════════════════════════
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
std.debug.print("\n", .{});
std.debug.print("╔═══════════════════════════════════════════════════════════════╗\n", .{});
std.debug.print("║ Compiled Zig Templates Benchmark ({d} iterations) ║\n", .{iterations});
std.debug.print("║ Templates: {s}/*.pug ║\n", .{templates_dir});
std.debug.print("╚═══════════════════════════════════════════════════════════════╝\n", .{});
// ─────────────────────────────────────────────────────────────────────────
// Load JSON data
// ─────────────────────────────────────────────────────────────────────────
std.debug.print("\nLoading JSON data...\n", .{});
var data_arena = std.heap.ArenaAllocator.init(allocator);
defer data_arena.deinit();
const data_alloc = data_arena.allocator();
// Load all JSON files
const simple0 = try loadJson(struct { name: []const u8 }, data_alloc, "simple-0.json");
const simple1 = try loadJson(struct {
name: []const u8,
messageCount: i64,
colors: []const []const u8,
primary: bool,
}, data_alloc, "simple-1.json");
const simple2 = try loadJson(struct {
header: []const u8,
header2: []const u8,
header3: []const u8,
header4: []const u8,
header5: []const u8,
header6: []const u8,
list: []const []const u8,
}, data_alloc, "simple-2.json");
const if_expr = try loadJson(struct { accounts: []const Account }, data_alloc, "if-expression.json");
const projects = try loadJson(struct {
title: []const u8,
text: []const u8,
projects: []const Project,
}, data_alloc, "projects-escaped.json");
const search = try loadJson(struct { searchRecords: []const SearchRecord }, data_alloc, "search-results.json");
const friends_data = try loadJson(struct { friends: []const Friend }, data_alloc, "friends.json");
std.debug.print("Loaded. Starting benchmark...\n\n", .{});
var total: f64 = 0;
// ─────────────────────────────────────────────────────────────────────────
// Benchmark each template
// ─────────────────────────────────────────────────────────────────────────
// simple-0
total += try bench("simple-0", allocator, tpls.simple_0, simple0);
// simple-1
total += try bench("simple-1", allocator, tpls.simple_1, simple1);
// simple-2
total += try bench("simple-2", allocator, tpls.simple_2, simple2);
// if-expression
total += try bench("if-expression", allocator, tpls.if_expression, if_expr);
// projects-escaped
total += try bench("projects-escaped", allocator, tpls.projects_escaped, projects);
// search-results
total += try bench("search-results", allocator, tpls.search_results, search);
// friends
total += try bench("friends", allocator, tpls.friends, friends_data);
// ─────────────────────────────────────────────────────────────────────────
// Summary
// ─────────────────────────────────────────────────────────────────────────
std.debug.print("\n", .{});
std.debug.print(" {s:<20} => {d:>7.1}ms\n", .{ "TOTAL", total });
std.debug.print("\n", .{});
}
fn loadJson(comptime T: type, alloc: std.mem.Allocator, comptime filename: []const u8) !T {
const path = templates_dir ++ "/" ++ filename;
const content = try std.fs.cwd().readFileAlloc(alloc, path, 10 * 1024 * 1024);
const parsed = try std.json.parseFromSlice(T, alloc, content, .{});
return parsed.value;
}
fn bench(
name: []const u8,
allocator: std.mem.Allocator,
comptime render_fn: anytype,
data: anytype,
) !f64 {
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
var timer = try std.time.Timer.start();
for (0..iterations) |_| {
_ = arena.reset(.retain_capacity);
_ = try render_fn(arena.allocator(), data);
}
const ms = @as(f64, @floatFromInt(timer.read())) / 1_000_000.0;
std.debug.print(" {s:<20} => {d:>7.1}ms\n", .{ name, ms });
return ms;
}

View File

@@ -1,388 +0,0 @@
//! Pugz Rendering Benchmark
//!
//! Measures template rendering performance with various template complexities.
//! Run with: zig build bench
//!
//! Metrics reported:
//! - Total time for N iterations
//! - Average time per render
//! - Renders per second
//! - Memory usage per render
const std = @import("std");
const pugz = @import("pugz");
const Allocator = std.mem.Allocator;
/// Benchmark configuration
const Config = struct {
warmup_iterations: usize = 200,
benchmark_iterations: usize = 20_000,
show_output: bool = false,
};
/// Benchmark result
const Result = struct {
name: []const u8,
iterations: usize,
total_ns: u64,
min_ns: u64,
max_ns: u64,
avg_ns: u64,
ops_per_sec: f64,
bytes_per_render: usize,
arena_peak_bytes: usize,
pub fn print(self: Result) void {
std.debug.print("\n{s}\n", .{self.name});
std.debug.print(" Iterations: {d:>10}\n", .{self.iterations});
std.debug.print(" Total time: {d:>10.2} ms\n", .{@as(f64, @floatFromInt(self.total_ns)) / 1_000_000.0});
std.debug.print(" Avg per render: {d:>10.2} us\n", .{@as(f64, @floatFromInt(self.avg_ns)) / 1_000.0});
std.debug.print(" Min: {d:>10.2} us\n", .{@as(f64, @floatFromInt(self.min_ns)) / 1_000.0});
std.debug.print(" Max: {d:>10.2} us\n", .{@as(f64, @floatFromInt(self.max_ns)) / 1_000.0});
std.debug.print(" Renders/sec: {d:>10.0}\n", .{self.ops_per_sec});
std.debug.print(" Output size: {d:>10} bytes\n", .{self.bytes_per_render});
std.debug.print(" Memory/render: {d:>10} bytes\n", .{self.arena_peak_bytes});
}
};
/// Run a benchmark for a template
fn runBenchmark(
allocator: Allocator,
comptime name: []const u8,
template: []const u8,
data: anytype,
config: Config,
) !Result {
// Warmup phase
for (0..config.warmup_iterations) |_| {
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
_ = try pugz.renderTemplate(arena.allocator(), template, data);
}
// Benchmark phase
var total_ns: u64 = 0;
var min_ns: u64 = std.math.maxInt(u64);
var max_ns: u64 = 0;
var output_size: usize = 0;
var peak_memory: usize = 0;
var timer = try std.time.Timer.start();
for (0..config.benchmark_iterations) |i| {
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
timer.reset();
const result = try pugz.renderTemplate(arena.allocator(), template, data);
const elapsed = timer.read();
total_ns += elapsed;
min_ns = @min(min_ns, elapsed);
max_ns = @max(max_ns, elapsed);
if (i == 0) {
output_size = result.len;
// Measure memory used by arena (query state before deinit)
const state = arena.queryCapacity();
peak_memory = state;
if (config.show_output) {
std.debug.print("\n--- {s} output ---\n{s}\n", .{ name, result });
}
}
}
const avg_ns = total_ns / config.benchmark_iterations;
const ops_per_sec = @as(f64, @floatFromInt(config.benchmark_iterations)) / (@as(f64, @floatFromInt(total_ns)) / 1_000_000_000.0);
return .{
.name = name,
.iterations = config.benchmark_iterations,
.total_ns = total_ns,
.min_ns = min_ns,
.max_ns = max_ns,
.avg_ns = avg_ns,
.ops_per_sec = ops_per_sec,
.bytes_per_render = output_size,
.arena_peak_bytes = peak_memory,
};
}
/// Simple template - just a few elements
const simple_template =
\\doctype html
\\html
\\ head
\\ title= title
\\ body
\\ h1 Hello, #{name}!
\\ p Welcome to our site.
;
/// Medium template - with conditionals and loops
const medium_template =
\\doctype html
\\html
\\ head
\\ title= title
\\ meta(charset="utf-8")
\\ meta(name="viewport" content="width=device-width, initial-scale=1")
\\ body
\\ header
\\ nav.navbar
\\ a.brand(href="/") Brand
\\ ul.nav-links
\\ each link in navLinks
\\ li
\\ a(href=link.href)= link.text
\\ main.container
\\ h1= title
\\ if showIntro
\\ p.intro Welcome, #{userName}!
\\ section.content
\\ each item in items
\\ .card
\\ h3= item.title
\\ p= item.description
\\ footer
\\ p Copyright 2024
;
/// Complex template - with mixins, nested loops, conditionals
const complex_template =
\\mixin card(title, description)
\\ .card
\\ .card-header
\\ h3= title
\\ .card-body
\\ p= description
\\ block
\\
\\mixin button(text, type="primary")
\\ button(class="btn btn-" + type)= text
\\
\\mixin navItem(href, text)
\\ li
\\ a(href=href)= text
\\
\\doctype html
\\html
\\ head
\\ title= title
\\ meta(charset="utf-8")
\\ meta(name="viewport" content="width=device-width, initial-scale=1")
\\ link(rel="stylesheet" href="/css/style.css")
\\ body
\\ header.site-header
\\ .container
\\ a.logo(href="/")
\\ img(src="/img/logo.png" alt="Logo")
\\ nav.main-nav
\\ ul
\\ each link in navLinks
\\ +navItem(link.href, link.text)
\\ .user-menu
\\ if user
\\ span.greeting Hello, #{user.name}!
\\ +button("Logout", "secondary")
\\ else
\\ +button("Login")
\\ +button("Sign Up", "success")
\\ main.site-content
\\ .container
\\ .page-header
\\ h1= pageTitle
\\ if subtitle
\\ p.subtitle= subtitle
\\ .content-grid
\\ each category in categories
\\ section.category
\\ h2= category.name
\\ .cards
\\ each item in category.items
\\ +card(item.title, item.description)
\\ .card-footer
\\ +button("View Details")
\\ aside.sidebar
\\ .widget
\\ h4 Recent Posts
\\ ul.post-list
\\ each post in recentPosts
\\ li
\\ a(href=post.url)= post.title
\\ .widget
\\ h4 Tags
\\ .tag-cloud
\\ each tag in allTags
\\ span.tag= tag
\\ footer.site-footer
\\ .container
\\ .footer-grid
\\ .footer-col
\\ h4 About
\\ p Some description text here.
\\ .footer-col
\\ h4 Links
\\ ul
\\ each link in footerLinks
\\ li
\\ a(href=link.href)= link.text
\\ .footer-col
\\ h4 Contact
\\ p Email: contact@example.com
\\ .copyright
\\ p Copyright #{year} Example Inc.
;
pub fn main() !void {
// Use GPA with leak detection enabled
var gpa = std.heap.GeneralPurposeAllocator(.{
.stack_trace_frames = 10,
.safety = true,
}){};
defer {
const leaked = gpa.deinit();
if (leaked == .leak) {
std.debug.print("\n⚠️ MEMORY LEAK DETECTED!\n", .{});
} else {
std.debug.print("\n✓ No memory leaks detected.\n", .{});
}
}
const allocator = gpa.allocator();
const config = Config{
.warmup_iterations = 200,
.benchmark_iterations = 20_000,
.show_output = false,
};
std.debug.print("\n", .{});
std.debug.print("╔══════════════════════════════════════════════════════════════╗\n", .{});
std.debug.print("║ Pugz Template Rendering Benchmark ║\n", .{});
std.debug.print("╠══════════════════════════════════════════════════════════════╣\n", .{});
std.debug.print("║ Warmup iterations: {d:>6} ║\n", .{config.warmup_iterations});
std.debug.print("║ Benchmark iterations: {d:>6} ║\n", .{config.benchmark_iterations});
std.debug.print("╚══════════════════════════════════════════════════════════════╝\n", .{});
// Simple template benchmark
const simple_result = try runBenchmark(
allocator,
"Simple Template (basic elements, interpolation)",
simple_template,
.{
.title = "Welcome",
.name = "World",
},
config,
);
simple_result.print();
// Medium template benchmark
const NavLink = struct { href: []const u8, text: []const u8 };
const Item = struct { title: []const u8, description: []const u8 };
const medium_result = try runBenchmark(
allocator,
"Medium Template (loops, conditionals, nested elements)",
medium_template,
.{
.title = "Dashboard",
.userName = "Alice",
.showIntro = true,
.navLinks = &[_]NavLink{
.{ .href = "/", .text = "Home" },
.{ .href = "/about", .text = "About" },
.{ .href = "/contact", .text = "Contact" },
},
.items = &[_]Item{
.{ .title = "Item 1", .description = "Description for item 1" },
.{ .title = "Item 2", .description = "Description for item 2" },
.{ .title = "Item 3", .description = "Description for item 3" },
.{ .title = "Item 4", .description = "Description for item 4" },
},
},
config,
);
medium_result.print();
// Complex template benchmark
const User = struct { name: []const u8 };
const SimpleItem = struct { title: []const u8, description: []const u8 };
const Category = struct { name: []const u8, items: []const SimpleItem };
const Post = struct { url: []const u8, title: []const u8 };
const FooterLink = struct { href: []const u8, text: []const u8 };
const complex_result = try runBenchmark(
allocator,
"Complex Template (mixins, nested loops, conditionals)",
complex_template,
.{
.title = "Example Site",
.pageTitle = "Welcome to Our Site",
.subtitle = "The best place on the web",
.year = "2024",
.user = User{ .name = "Alice" },
.navLinks = &[_]NavLink{
.{ .href = "/", .text = "Home" },
.{ .href = "/products", .text = "Products" },
.{ .href = "/about", .text = "About" },
.{ .href = "/contact", .text = "Contact" },
},
.categories = &[_]Category{
.{
.name = "Featured",
.items = &[_]SimpleItem{
.{ .title = "Product A", .description = "Amazing product A" },
.{ .title = "Product B", .description = "Wonderful product B" },
},
},
.{
.name = "Popular",
.items = &[_]SimpleItem{
.{ .title = "Product C", .description = "Popular product C" },
.{ .title = "Product D", .description = "Trending product D" },
},
},
},
.recentPosts = &[_]Post{
.{ .url = "/blog/post-1", .title = "First Blog Post" },
.{ .url = "/blog/post-2", .title = "Second Blog Post" },
.{ .url = "/blog/post-3", .title = "Third Blog Post" },
},
.allTags = &[_][]const u8{ "tech", "news", "tutorial", "review", "guide" },
.footerLinks = &[_]FooterLink{
.{ .href = "/privacy", .text = "Privacy Policy" },
.{ .href = "/terms", .text = "Terms of Service" },
.{ .href = "/sitemap", .text = "Sitemap" },
},
},
config,
);
complex_result.print();
// Summary
std.debug.print("\n", .{});
std.debug.print("╔══════════════════════════════════════════════════════════════╗\n", .{});
std.debug.print("║ Summary ║\n", .{});
std.debug.print("╠══════════════════════════════════════════════════════════════╣\n", .{});
std.debug.print("║ Template │ Avg (us) │ Renders/sec │ Output (bytes) ║\n", .{});
std.debug.print("╠──────────────────┼──────────┼─────────────┼─────────────────╣\n", .{});
std.debug.print("║ Simple │ {d:>8.2} │ {d:>11.0} │ {d:>15} ║\n", .{
@as(f64, @floatFromInt(simple_result.avg_ns)) / 1_000.0,
simple_result.ops_per_sec,
simple_result.bytes_per_render,
});
std.debug.print("║ Medium │ {d:>8.2} │ {d:>11.0} │ {d:>15} ║\n", .{
@as(f64, @floatFromInt(medium_result.avg_ns)) / 1_000.0,
medium_result.ops_per_sec,
medium_result.bytes_per_render,
});
std.debug.print("║ Complex │ {d:>8.2} │ {d:>11.0} │ {d:>15} ║\n", .{
@as(f64, @floatFromInt(complex_result.avg_ns)) / 1_000.0,
complex_result.ops_per_sec,
complex_result.bytes_per_render,
});
std.debug.print("╚══════════════════════════════════════════════════════════════╝\n", .{});
std.debug.print("\n", .{});
}

View File

@@ -1,479 +0,0 @@
//! Pugz Benchmark - Comparison with template-engine-bench
//!
//! These benchmarks use the exact same templates from:
//! https://github.com/itsarnaud/template-engine-bench
//!
//! Run individual benchmarks:
//! zig build test-bench -- simple-0
//! zig build test-bench -- friends
//!
//! Run all benchmarks:
//! zig build test-bench
//!
//! Pug.js reference (2000 iterations on MacBook Air M2):
//! - simple-0: pug => 2ms
//! - simple-1: pug => 9ms
//! - simple-2: pug => 9ms
//! - if-expression: pug => 12ms
//! - projects-escaped: pug => 86ms
//! - search-results: pug => 41ms
//! - friends: pug => 110ms
const std = @import("std");
const pugz = @import("pugz");
const iterations: usize = 2000;
// ═══════════════════════════════════════════════════════════════════════════
// simple-0
// ═══════════════════════════════════════════════════════════════════════════
const simple_0_tpl = "h1 Hello, #{name}";
test "bench: simple-0" {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer if (gpa.deinit() == .leak) @panic("leak!");
const engine = pugz.ViewEngine.init(.{});
var arena = std.heap.ArenaAllocator.init(gpa.allocator());
defer arena.deinit();
var total_ns: u64 = 0;
var timer = try std.time.Timer.start();
for (0..iterations) |_| {
_ = arena.reset(.retain_capacity);
timer.reset();
_ = try engine.renderTpl(arena.allocator(), simple_0_tpl, .{
.name = "John",
});
total_ns += timer.read();
}
printResult("simple-0", total_ns, 2);
}
// ═══════════════════════════════════════════════════════════════════════════
// simple-1
// ═══════════════════════════════════════════════════════════════════════════
const simple_1_tpl =
\\.simple-1(style="background-color: blue; border: 1px solid black")
\\ .colors
\\ span.hello Hello #{name}!
\\ strong You have #{messageCount} messages!
\\ if colors
\\ ul
\\ each color in colors
\\ li.color= color
\\ else
\\ div No colors!
\\ if primary
\\ button(type="button" class="primary") Click me!
\\ else
\\ button(type="button" class="secondary") Click me!
;
test "bench: simple-1" {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const engine = pugz.ViewEngine.init(.{});
var arena = std.heap.ArenaAllocator.init(gpa.allocator());
defer arena.deinit();
var total_ns: u64 = 0;
var timer = try std.time.Timer.start();
const data = .{
.name = "George Washington",
.messageCount = 999,
.colors = &[_][]const u8{ "red", "green", "blue", "yellow", "orange", "pink", "black", "white", "beige", "brown", "cyan", "magenta" },
.primary = true,
};
for (0..iterations) |_| {
_ = arena.reset(.retain_capacity);
timer.reset();
_ = try engine.renderTpl(arena.allocator(), simple_1_tpl, data);
total_ns += timer.read();
}
printResult("simple-1", total_ns, 9);
}
// ═══════════════════════════════════════════════════════════════════════════
// simple-2
// ═══════════════════════════════════════════════════════════════════════════
const simple_2_tpl =
\\div
\\ h1.header #{header}
\\ h2.header2 #{header2}
\\ h3.header3 #{header3}
\\ h4.header4 #{header4}
\\ h5.header5 #{header5}
\\ h6.header6 #{header6}
\\ ul.list
\\ each item in list
\\ li.item #{item}
;
test "bench: simple-2" {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const engine = pugz.ViewEngine.init(.{});
var arena = std.heap.ArenaAllocator.init(gpa.allocator());
defer arena.deinit();
var total_ns: u64 = 0;
var timer = try std.time.Timer.start();
const data = .{
.header = "Header",
.header2 = "Header2",
.header3 = "Header3",
.header4 = "Header4",
.header5 = "Header5",
.header6 = "Header6",
.list = &[_][]const u8{ "1000000000", "2", "3", "4", "5", "6", "7", "8", "9", "10" },
};
for (0..iterations) |_| {
_ = arena.reset(.retain_capacity);
timer.reset();
_ = try engine.renderTpl(arena.allocator(), simple_2_tpl, data);
total_ns += timer.read();
}
printResult("simple-2", total_ns, 9);
}
// ═══════════════════════════════════════════════════════════════════════════
// if-expression
// ═══════════════════════════════════════════════════════════════════════════
const if_expression_tpl =
\\each account in accounts
\\ div
\\ if account.status == "closed"
\\ div Your account has been closed!
\\ if account.status == "suspended"
\\ div Your account has been temporarily suspended
\\ if account.status == "open"
\\ div
\\ | Bank balance:
\\ if account.negative
\\ span.negative= account.balanceFormatted
\\ else
\\ span.positive= account.balanceFormatted
;
const Account = struct {
balance: i32,
balanceFormatted: []const u8,
status: []const u8,
negative: bool,
};
test "bench: if-expression" {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const engine = pugz.ViewEngine.init(.{});
var arena = std.heap.ArenaAllocator.init(gpa.allocator());
defer arena.deinit();
var total_ns: u64 = 0;
var timer = try std.time.Timer.start();
const data = .{
.accounts = &[_]Account{
.{ .balance = 0, .balanceFormatted = "$0.00", .status = "open", .negative = false },
.{ .balance = 10, .balanceFormatted = "$10.00", .status = "closed", .negative = false },
.{ .balance = -100, .balanceFormatted = "$-100.00", .status = "suspended", .negative = true },
.{ .balance = 999, .balanceFormatted = "$999.00", .status = "open", .negative = false },
},
};
for (0..iterations) |_| {
_ = arena.reset(.retain_capacity);
timer.reset();
_ = try engine.renderTpl(arena.allocator(), if_expression_tpl, data);
total_ns += timer.read();
}
printResult("if-expression", total_ns, 12);
}
// ═══════════════════════════════════════════════════════════════════════════
// projects-escaped
// ═══════════════════════════════════════════════════════════════════════════
const projects_escaped_tpl =
\\doctype html
\\html
\\ head
\\ title #{title}
\\ body
\\ p #{text}
\\ each project in projects
\\ a(href=project.url) #{project.name}
\\ p #{project.description}
\\ else
\\ p No projects
;
const Project = struct {
name: []const u8,
url: []const u8,
description: []const u8,
};
test "bench: projects-escaped" {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const engine = pugz.ViewEngine.init(.{});
var arena = std.heap.ArenaAllocator.init(gpa.allocator());
defer arena.deinit();
var total_ns: u64 = 0;
var timer = try std.time.Timer.start();
const data = .{
.title = "Projects",
.text = "<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>",
.projects = &[_]Project{
.{ .name = "<strong>Facebook</strong>", .url = "http://facebook.com", .description = "Social network" },
.{ .name = "<strong>Google</strong>", .url = "http://google.com", .description = "Search engine" },
.{ .name = "<strong>Twitter</strong>", .url = "http://twitter.com", .description = "Microblogging service" },
.{ .name = "<strong>Amazon</strong>", .url = "http://amazon.com", .description = "Online retailer" },
.{ .name = "<strong>eBay</strong>", .url = "http://ebay.com", .description = "Online auction" },
.{ .name = "<strong>Wikipedia</strong>", .url = "http://wikipedia.org", .description = "A free encyclopedia" },
.{ .name = "<strong>LiveJournal</strong>", .url = "http://livejournal.com", .description = "Blogging platform" },
},
};
for (0..iterations) |_| {
_ = arena.reset(.retain_capacity);
timer.reset();
_ = try engine.renderTpl(arena.allocator(), projects_escaped_tpl, data);
total_ns += timer.read();
}
printResult("projects-escaped", total_ns, 86);
}
// ═══════════════════════════════════════════════════════════════════════════
// search-results
// ═══════════════════════════════════════════════════════════════════════════
// Simplified to match original JS benchmark template exactly
const search_results_tpl =
\\.search-results.view-gallery
\\ each searchRecord in searchRecords
\\ .search-item
\\ .search-item-container.drop-shadow
\\ .img-container
\\ img(src=searchRecord.imgUrl)
\\ h4.title
\\ a(href=searchRecord.viewItemUrl)= searchRecord.title
\\ | #{searchRecord.description}
\\ if searchRecord.featured
\\ div Featured!
\\ if searchRecord.sizes
\\ div
\\ | Sizes available:
\\ ul
\\ each size in searchRecord.sizes
\\ li= size
;
const SearchRecord = struct {
imgUrl: []const u8,
viewItemUrl: []const u8,
title: []const u8,
description: []const u8,
featured: bool,
sizes: ?[]const []const u8,
};
test "bench: search-results" {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const engine = pugz.ViewEngine.init(.{});
var arena = std.heap.ArenaAllocator.init(gpa.allocator());
defer arena.deinit();
const sizes = &[_][]const u8{ "S", "M", "L", "XL", "XXL" };
// Long descriptions matching original benchmark (Lorem ipsum paragraphs)
const desc1 = "Duis laborum nostrud consectetur exercitation minim ad laborum velit adipisicing. Dolore adipisicing pariatur in fugiat nulla voluptate aliquip esse laboris quis exercitation aliqua labore.";
const desc2 = "Incididunt ea mollit commodo velit officia. Enim officia occaecat nulla aute. Esse sunt laborum excepteur sint elit sit esse ad.";
const desc3 = "Aliquip Lorem consequat sunt ipsum dolor amet amet cupidatat deserunt eiusmod qui anim cillum sint. Dolor exercitation tempor aliquip sunt nisi ipsum ullamco adipisicing.";
const desc4 = "Est ad amet irure veniam dolore velit amet irure fugiat ut elit. Tempor fugiat dolor tempor aute enim. Ad sint mollit laboris id sint ullamco eu do irure nostrud magna sunt voluptate.";
const desc5 = "Sunt ex magna culpa cillum esse irure consequat Lorem aliquip enim sit reprehenderit sunt. Exercitation esse irure magna proident ex ut elit magna mollit aliqua amet.";
var total_ns: u64 = 0;
var timer = try std.time.Timer.start();
const data = .{
.searchRecords = &[_]SearchRecord{
.{ .imgUrl = "img1.jpg", .viewItemUrl = "http://foo/1", .title = "Namebox", .description = desc1, .featured = true, .sizes = sizes },
.{ .imgUrl = "img2.jpg", .viewItemUrl = "http://foo/2", .title = "Arctiq", .description = desc2, .featured = false, .sizes = sizes },
.{ .imgUrl = "img3.jpg", .viewItemUrl = "http://foo/3", .title = "Niquent", .description = desc3, .featured = true, .sizes = sizes },
.{ .imgUrl = "img4.jpg", .viewItemUrl = "http://foo/4", .title = "Remotion", .description = desc4, .featured = true, .sizes = sizes },
.{ .imgUrl = "img5.jpg", .viewItemUrl = "http://foo/5", .title = "Octocore", .description = desc5, .featured = true, .sizes = sizes },
.{ .imgUrl = "img6.jpg", .viewItemUrl = "http://foo/6", .title = "Spherix", .description = desc1, .featured = true, .sizes = sizes },
.{ .imgUrl = "img7.jpg", .viewItemUrl = "http://foo/7", .title = "Quarex", .description = desc2, .featured = true, .sizes = sizes },
.{ .imgUrl = "img8.jpg", .viewItemUrl = "http://foo/8", .title = "Supremia", .description = desc3, .featured = false, .sizes = sizes },
.{ .imgUrl = "img9.jpg", .viewItemUrl = "http://foo/9", .title = "Amtap", .description = desc4, .featured = false, .sizes = sizes },
.{ .imgUrl = "img10.jpg", .viewItemUrl = "http://foo/10", .title = "Qiao", .description = desc5, .featured = false, .sizes = sizes },
.{ .imgUrl = "img11.jpg", .viewItemUrl = "http://foo/11", .title = "Pushcart", .description = desc1, .featured = true, .sizes = sizes },
.{ .imgUrl = "img12.jpg", .viewItemUrl = "http://foo/12", .title = "Eweville", .description = desc2, .featured = false, .sizes = sizes },
.{ .imgUrl = "img13.jpg", .viewItemUrl = "http://foo/13", .title = "Senmei", .description = desc3, .featured = true, .sizes = sizes },
.{ .imgUrl = "img14.jpg", .viewItemUrl = "http://foo/14", .title = "Maximind", .description = desc4, .featured = true, .sizes = sizes },
.{ .imgUrl = "img15.jpg", .viewItemUrl = "http://foo/15", .title = "Blurrybus", .description = desc5, .featured = true, .sizes = sizes },
.{ .imgUrl = "img16.jpg", .viewItemUrl = "http://foo/16", .title = "Virva", .description = desc1, .featured = true, .sizes = sizes },
.{ .imgUrl = "img17.jpg", .viewItemUrl = "http://foo/17", .title = "Centregy", .description = desc2, .featured = true, .sizes = sizes },
.{ .imgUrl = "img18.jpg", .viewItemUrl = "http://foo/18", .title = "Dancerity", .description = desc3, .featured = true, .sizes = sizes },
.{ .imgUrl = "img19.jpg", .viewItemUrl = "http://foo/19", .title = "Oceanica", .description = desc4, .featured = true, .sizes = sizes },
.{ .imgUrl = "img20.jpg", .viewItemUrl = "http://foo/20", .title = "Synkgen", .description = desc5, .featured = false, .sizes = null },
},
};
for (0..iterations) |_| {
_ = arena.reset(.retain_capacity);
timer.reset();
_ = try engine.renderTpl(arena.allocator(), search_results_tpl, data);
total_ns += timer.read();
}
printResult("search-results", total_ns, 41);
}
// ═══════════════════════════════════════════════════════════════════════════
// friends
// ═══════════════════════════════════════════════════════════════════════════
const friends_tpl =
\\doctype html
\\html(lang="en")
\\ head
\\ meta(charset="UTF-8")
\\ title Friends
\\ body
\\ div.friends
\\ each friend in friends
\\ div.friend
\\ ul
\\ li Name: #{friend.name}
\\ li Balance: #{friend.balance}
\\ li Age: #{friend.age}
\\ li Address: #{friend.address}
\\ li Image:
\\ img(src=friend.picture)
\\ li Company: #{friend.company}
\\ li Email:
\\ a(href=friend.emailHref) #{friend.email}
\\ li About: #{friend.about}
\\ if friend.tags
\\ li Tags:
\\ ul
\\ each tag in friend.tags
\\ li #{tag}
\\ if friend.friends
\\ li Friends:
\\ ul
\\ each subFriend in friend.friends
\\ li #{subFriend.name} (#{subFriend.id})
;
const SubFriend = struct {
id: i32,
name: []const u8,
};
const Friend = struct {
name: []const u8,
balance: []const u8,
age: i32,
address: []const u8,
picture: []const u8,
company: []const u8,
email: []const u8,
emailHref: []const u8,
about: []const u8,
tags: ?[]const []const u8,
friends: ?[]const SubFriend,
};
test "bench: friends" {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer if (gpa.deinit() == .leak) @panic("leadk");
const engine = pugz.ViewEngine.init(.{});
var arena = std.heap.ArenaAllocator.init(gpa.allocator());
defer arena.deinit();
const friend_tags = &[_][]const u8{ "id", "amet", "non", "ut", "dolore", "commodo", "consequat" };
const sub_friends = &[_]SubFriend{
.{ .id = 0, .name = "Gates Lewis" },
.{ .id = 1, .name = "Britt Stokes" },
.{ .id = 2, .name = "Reed Wade" },
};
var friends_data: [100]Friend = undefined;
for (&friends_data, 0..) |*f, i| {
f.* = .{
.name = "Gardner Alvarez",
.balance = "$1,509.00",
.age = 30 + @as(i32, @intCast(i % 20)),
.address = "282 Lancaster Avenue, Bowden, Kansas, 666",
.picture = "http://placehold.it/32x32",
.company = "Dentrex",
.email = "gardneralvarez@dentrex.com",
.emailHref = "mailto:gardneralvarez@dentrex.com",
.about = "Minim elit tempor enim voluptate labore do non nisi sint nulla deserunt officia proident excepteur.",
.tags = friend_tags,
.friends = sub_friends,
};
}
var total_ns: u64 = 0;
var timer = try std.time.Timer.start();
for (0..iterations) |_| {
_ = arena.reset(.retain_capacity);
timer.reset();
_ = try engine.renderTpl(arena.allocator(), friends_tpl, .{
.friends = &friends_data,
});
total_ns += timer.read();
}
printResult("friends", total_ns, 110);
}
// ═══════════════════════════════════════════════════════════════════════════
// Helper
// ═══════════════════════════════════════════════════════════════════════════
fn printResult(name: []const u8, total_ns: u64, pug_ref_ms: f64) void {
const total_ms = @as(f64, @floatFromInt(total_ns)) / 1_000_000.0;
const avg_us = @as(f64, @floatFromInt(total_ns)) / @as(f64, @floatFromInt(iterations)) / 1_000.0;
const speedup = pug_ref_ms / total_ms;
std.debug.print("\n{s:<20} => {d:>6.1}ms ({d:.2}us/render) | Pug.js: {d:.0}ms | {d:.1}x\n", .{
name,
total_ms,
avg_us,
pug_ref_ms,
speedup,
});
}

View File

@@ -1,170 +0,0 @@
const std = @import("std");
const pugz = @import("pugz");
const friends_tpl =
\\doctype html
\\html(lang="en")
\\ head
\\ meta(charset="UTF-8")
\\ title Friends
\\ body
\\ div.friends
\\ each friend in friends
\\ div.friend
\\ ul
\\ li Name: #{friend.name}
\\ li Balance: #{friend.balance}
\\ li Age: #{friend.age}
\\ li Address: #{friend.address}
\\ li Image:
\\ img(src=friend.picture)
\\ li Company: #{friend.company}
\\ li Email:
\\ a(href=friend.emailHref) #{friend.email}
\\ li About: #{friend.about}
\\ if friend.tags
\\ li Tags:
\\ ul
\\ each tag in friend.tags
\\ li #{tag}
\\ if friend.friends
\\ li Friends:
\\ ul
\\ each subFriend in friend.friends
\\ li #{subFriend.name} (#{subFriend.id})
;
const SubFriend = struct { id: i32, name: []const u8 };
const Friend = struct {
name: []const u8,
balance: []const u8,
age: i32,
address: []const u8,
picture: []const u8,
company: []const u8,
email: []const u8,
emailHref: []const u8,
about: []const u8,
tags: ?[]const []const u8,
friends: ?[]const SubFriend,
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
var arena = std.heap.ArenaAllocator.init(gpa.allocator());
defer arena.deinit();
const engine = pugz.ViewEngine.init(.{});
const friend_tags = &[_][]const u8{ "id", "amet", "non", "ut", "dolore", "commodo", "consequat" };
const sub_friends = &[_]SubFriend{
.{ .id = 0, .name = "Gates Lewis" },
.{ .id = 1, .name = "Britt Stokes" },
.{ .id = 2, .name = "Reed Wade" },
};
var friends_data: [100]Friend = undefined;
for (&friends_data, 0..) |*f, i| {
f.* = .{
.name = "Gardner Alvarez",
.balance = "$1,509.00",
.age = 30 + @as(i32, @intCast(i % 20)),
.address = "282 Lancaster Avenue, Bowden, Kansas, 666",
.picture = "http://placehold.it/32x32",
.company = "Dentrex",
.email = "gardneralvarez@dentrex.com",
.emailHref = "mailto:gardneralvarez@dentrex.com",
.about = "Minim elit tempor enim voluptate labore do non nisi sint nulla deserunt officia proident excepteur.",
.tags = friend_tags,
.friends = sub_friends,
};
}
const data = .{ .friends = &friends_data };
// Warmup
for (0..10) |_| {
_ = arena.reset(.retain_capacity);
_ = try engine.renderTpl(arena.allocator(), friends_tpl, data);
}
// Get output size
_ = arena.reset(.retain_capacity);
const output = try engine.renderTpl(arena.allocator(), friends_tpl, data);
const output_size = output.len;
// Profile render
const iterations: usize = 500;
var total_render: u64 = 0;
var timer = try std.time.Timer.start();
for (0..iterations) |_| {
_ = arena.reset(.retain_capacity);
timer.reset();
_ = try engine.renderTpl(arena.allocator(), friends_tpl, data);
total_render += timer.read();
}
const avg_render_us = @as(f64, @floatFromInt(total_render)) / @as(f64, @floatFromInt(iterations)) / 1000.0;
const total_ms = @as(f64, @floatFromInt(total_render)) / 1_000_000.0;
// Header
std.debug.print("\n", .{});
std.debug.print("╔══════════════════════════════════════════════════════════════╗\n", .{});
std.debug.print("║ FRIENDS TEMPLATE CPU PROFILE ║\n", .{});
std.debug.print("╠══════════════════════════════════════════════════════════════╣\n", .{});
std.debug.print("║ Iterations: {d:<6} Output size: {d:<6} bytes ║\n", .{ iterations, output_size });
std.debug.print("╚══════════════════════════════════════════════════════════════╝\n\n", .{});
// Results
std.debug.print("┌────────────────────────────────────┬─────────────────────────┐\n", .{});
std.debug.print("│ Metric │ Value │\n", .{});
std.debug.print("├────────────────────────────────────┼─────────────────────────┤\n", .{});
std.debug.print("│ Total time │ {d:>10.1} ms │\n", .{total_ms});
std.debug.print("│ Avg per render │ {d:>10.1} µs │\n", .{avg_render_us});
std.debug.print("│ Renders/sec │ {d:>10.0} │\n", .{1_000_000.0 / avg_render_us});
std.debug.print("└────────────────────────────────────┴─────────────────────────┘\n", .{});
// Template complexity breakdown
std.debug.print("\n📋 Template Complexity:\n", .{});
std.debug.print(" • 100 friends (outer loop)\n", .{});
std.debug.print(" • 7 tags per friend (nested loop) = 700 tag iterations\n", .{});
std.debug.print(" • 3 sub-friends per friend (nested loop) = 300 sub-friend iterations\n", .{});
std.debug.print(" • Total loop iterations: 100 + 700 + 300 = 1,100\n", .{});
std.debug.print(" • ~10 interpolations per friend = 1,000+ variable lookups\n", .{});
std.debug.print(" • 2 conditionals per friend = 200 conditional evaluations\n", .{});
// Cost breakdown estimate
const loop_iterations: f64 = 1100;
const var_lookups: f64 = 1500; // approximate
std.debug.print("\n💡 Estimated Cost Breakdown (per render):\n", .{});
std.debug.print(" Total: {d:.1} µs\n", .{avg_render_us});
std.debug.print(" Per loop iteration: ~{d:.2} µs ({d:.0} iterations)\n", .{ avg_render_us / loop_iterations, loop_iterations });
std.debug.print(" Per variable lookup: ~{d:.3} µs ({d:.0} lookups)\n", .{ avg_render_us / var_lookups, var_lookups });
// Comparison
std.debug.print("\n📊 Comparison with Pug.js:\n", .{});
const pugjs_us: f64 = 55.0; // From benchmark: 110ms / 2000 = 55µs
std.debug.print(" Pug.js: {d:.1} µs/render\n", .{pugjs_us});
std.debug.print(" Pugz: {d:.1} µs/render\n", .{avg_render_us});
const ratio = avg_render_us / pugjs_us;
if (ratio > 1.0) {
std.debug.print(" Status: Pugz is {d:.1}x SLOWER\n", .{ratio});
} else {
std.debug.print(" Status: Pugz is {d:.1}x FASTER\n", .{1.0 / ratio});
}
std.debug.print("\nKey Bottlenecks (likely):\n", .{});
std.debug.print(" 1. Data conversion: Zig struct -> pugz.Value (comptime reflection)\n", .{});
std.debug.print(" 2. Variable lookup: HashMap get() for each interpolation\n", .{});
std.debug.print(" 3. AST traversal: Walking tree nodes vs Pug.js compiled JS functions\n", .{});
std.debug.print(" 4. Loop scope: Creating/clearing scope per loop iteration\n", .{});
std.debug.print("\nAlready optimized:\n", .{});
std.debug.print(" - Scope pooling (reuse hashmap capacity)\n", .{});
std.debug.print(" - Batched HTML escaping\n", .{});
std.debug.print(" - Arena allocator with retain_capacity\n", .{});
}

686
src/build_templates.zig Normal file
View File

@@ -0,0 +1,686 @@
//! Pugz Build Step - Compile .pug templates to Zig code at build time.
//!
//! Generates a single `generated.zig` file in the views folder containing:
//! - Shared helper functions (esc, truthy)
//! - All compiled template render functions
//!
//! ## Usage in build.zig:
//! ```zig
//! const build_templates = @import("pugz").build_templates;
//! const templates = build_templates.compileTemplates(b, .{
//! .source_dir = "views",
//! });
//! exe.root_module.addImport("templates", templates);
//! ```
//!
//! ## Usage in code:
//! ```zig
//! const tpls = @import("templates");
//! const html = try tpls.home(allocator, .{ .title = "Welcome" });
//! ```
const std = @import("std");
const Lexer = @import("lexer.zig").Lexer;
const Parser = @import("parser.zig").Parser;
const ast = @import("ast.zig");
pub const Options = struct {
source_dir: []const u8 = "views",
extension: []const u8 = ".pug",
};
pub fn compileTemplates(b: *std.Build, options: Options) *std.Build.Module {
const gen_step = TemplateGenStep.create(b, options);
return b.createModule(.{
.root_source_file = gen_step.getOutput(),
});
}
const TemplateGenStep = struct {
step: std.Build.Step,
options: Options,
generated_file: std.Build.GeneratedFile,
fn create(b: *std.Build, options: Options) *TemplateGenStep {
const self = b.allocator.create(TemplateGenStep) catch @panic("OOM");
self.* = .{
.step = std.Build.Step.init(.{
.id = .custom,
.name = "pugz-compile-templates",
.owner = b,
.makeFn = make,
}),
.options = options,
.generated_file = .{ .step = &self.step },
};
return self;
}
fn getOutput(self: *TemplateGenStep) std.Build.LazyPath {
return .{ .generated = .{ .file = &self.generated_file } };
}
fn make(step: *std.Build.Step, _: std.Build.Step.MakeOptions) !void {
const self: *TemplateGenStep = @fieldParentPtr("step", step);
const b = step.owner;
const allocator = b.allocator;
var templates = std.ArrayListUnmanaged(TemplateInfo){};
defer templates.deinit(allocator);
try findTemplates(allocator, self.options.source_dir, "", self.options.extension, &templates);
const out_path = try std.fs.path.join(allocator, &.{ self.options.source_dir, "generated.zig" });
try generateSingleFile(allocator, self.options.source_dir, out_path, templates.items);
self.generated_file.path = out_path;
}
};
const TemplateInfo = struct {
rel_path: []const u8,
zig_name: []const u8,
};
fn findTemplates(
allocator: std.mem.Allocator,
base_dir: []const u8,
sub_path: []const u8,
extension: []const u8,
templates: *std.ArrayListUnmanaged(TemplateInfo),
) !void {
const full_path = if (sub_path.len > 0)
try std.fs.path.join(allocator, &.{ base_dir, sub_path })
else
try allocator.dupe(u8, base_dir);
defer allocator.free(full_path);
var dir = std.fs.cwd().openDir(full_path, .{ .iterate = true }) catch |err| {
std.log.warn("Cannot open directory {s}: {}", .{ full_path, err });
return;
};
defer dir.close();
var iter = dir.iterate();
while (try iter.next()) |entry| {
const name = try allocator.dupe(u8, entry.name);
if (entry.kind == .directory) {
const new_sub = if (sub_path.len > 0)
try std.fs.path.join(allocator, &.{ sub_path, name })
else
name;
try findTemplates(allocator, base_dir, new_sub, extension, templates);
} else if (entry.kind == .file and std.mem.endsWith(u8, name, extension)) {
const rel_path = if (sub_path.len > 0)
try std.fs.path.join(allocator, &.{ sub_path, name })
else
name;
const without_ext = rel_path[0 .. rel_path.len - extension.len];
const zig_name = try pathToIdent(allocator, without_ext);
try templates.append(allocator, .{
.rel_path = rel_path,
.zig_name = zig_name,
});
}
}
}
fn pathToIdent(allocator: std.mem.Allocator, path: []const u8) ![]u8 {
var result = try allocator.alloc(u8, path.len);
for (path, 0..) |c, i| {
result[i] = switch (c) {
'/', '\\', '-', '.' => '_',
else => c,
};
}
return result;
}
fn generateSingleFile(
allocator: std.mem.Allocator,
source_dir: []const u8,
out_path: []const u8,
templates: []const TemplateInfo,
) !void {
var out = std.ArrayListUnmanaged(u8){};
defer out.deinit(allocator);
const w = out.writer(allocator);
// Header
try w.writeAll(
\\//! Auto-generated by pugz.compileTemplates()
\\//! Do not edit manually - regenerate by running: zig build
\\
\\const std = @import("std");
\\const Allocator = std.mem.Allocator;
\\const ArrayList = std.ArrayList(u8);
\\
\\// ─────────────────────────────────────────────────────────────────────────────
\\// Helpers
\\// ─────────────────────────────────────────────────────────────────────────────
\\
\\const esc_lut: [256]?[]const u8 = blk: {
\\ var t: [256]?[]const u8 = .{null} ** 256;
\\ t['&'] = "&amp;";
\\ t['<'] = "&lt;";
\\ t['>'] = "&gt;";
\\ t['"'] = "&quot;";
\\ t['\''] = "&#x27;";
\\ break :blk t;
\\};
\\
\\fn esc(o: *ArrayList, a: Allocator, s: []const u8) Allocator.Error!void {
\\ var i: usize = 0;
\\ for (s, 0..) |c, j| {
\\ if (esc_lut[c]) |e| {
\\ if (j > i) try o.appendSlice(a, s[i..j]);
\\ try o.appendSlice(a, e);
\\ i = j + 1;
\\ }
\\ }
\\ if (i < s.len) try o.appendSlice(a, s[i..]);
\\}
\\
\\fn truthy(v: anytype) bool {
\\ return switch (@typeInfo(@TypeOf(v))) {
\\ .bool => v,
\\ .optional => v != null,
\\ .pointer => |p| if (p.size == .slice) v.len > 0 else true,
\\ .int, .comptime_int => v != 0,
\\ else => true,
\\ };
\\}
\\
\\var int_buf: [32]u8 = undefined;
\\
\\fn strVal(v: anytype) []const u8 {
\\ const T = @TypeOf(v);
\\ return switch (@typeInfo(T)) {
\\ .pointer => |p| if (p.size == .slice) v else @compileError("unsupported pointer type"),
\\ .int, .comptime_int => std.fmt.bufPrint(&int_buf, "{d}", .{v}) catch "0",
\\ .optional => if (v) |val| strVal(val) else "",
\\ else => @compileError("strVal: unsupported type " ++ @typeName(T)),
\\ };
\\}
\\
\\// ─────────────────────────────────────────────────────────────────────────────
\\// Templates
\\// ─────────────────────────────────────────────────────────────────────────────
\\
\\
);
// Generate each template
for (templates) |tpl| {
const src_path = try std.fs.path.join(allocator, &.{ source_dir, tpl.rel_path });
defer allocator.free(src_path);
const source = std.fs.cwd().readFileAlloc(allocator, src_path, 5 * 1024 * 1024) catch |err| {
std.log.err("Failed to read {s}: {}", .{ src_path, err });
return err;
};
defer allocator.free(source);
try compileTemplate(allocator, w, tpl.zig_name, source);
}
// Template names list
try w.writeAll("pub const template_names = [_][]const u8{\n");
for (templates) |tpl| {
try w.print(" \"{s}\",\n", .{tpl.zig_name});
}
try w.writeAll("};\n");
const file = try std.fs.cwd().createFile(out_path, .{});
defer file.close();
try file.writeAll(out.items);
}
fn compileTemplate(
allocator: std.mem.Allocator,
w: std.ArrayListUnmanaged(u8).Writer,
name: []const u8,
source: []const u8,
) !void {
var lexer = Lexer.init(allocator, source);
defer lexer.deinit();
const tokens = lexer.tokenize() catch |err| {
std.log.err("Tokenize error in '{s}': {}", .{ name, err });
return err;
};
var parser = Parser.init(allocator, tokens);
const doc = parser.parse() catch |err| {
std.log.err("Parse error in '{s}': {}", .{ name, err });
return err;
};
// Check if template has content
var has_content = false;
for (doc.nodes) |node| {
if (nodeHasOutput(node)) {
has_content = true;
break;
}
}
// Check if template has any dynamic content
var has_dynamic = false;
for (doc.nodes) |node| {
if (nodeHasDynamic(node)) {
has_dynamic = true;
break;
}
}
try w.print("pub fn {s}(a: Allocator, d: anytype) Allocator.Error![]u8 {{\n", .{name});
if (!has_content) {
// Empty template (extends-only, mixin definitions, etc.)
try w.writeAll(" _ = .{ a, d };\n");
try w.writeAll(" return \"\";\n");
} else if (!has_dynamic) {
// Static-only template - return literal string, no allocation
try w.writeAll(" _ = .{ a, d };\n");
var compiler = Compiler.init(allocator, w);
try w.writeAll(" return ");
for (doc.nodes) |node| {
try compiler.emitNode(node);
}
try compiler.flushAsReturn();
} else {
// Dynamic template - needs ArrayList
try w.writeAll(" var o: ArrayList = .empty;\n");
var compiler = Compiler.init(allocator, w);
for (doc.nodes) |node| {
try compiler.emitNode(node);
}
try compiler.flush();
try w.writeAll(" return o.items;\n");
}
try w.writeAll("}\n\n");
}
fn nodeHasOutput(node: ast.Node) bool {
return switch (node) {
.doctype, .element, .text, .raw_text, .comment => true,
.conditional => |c| blk: {
for (c.branches) |br| {
for (br.children) |child| {
if (nodeHasOutput(child)) break :blk true;
}
}
break :blk false;
},
.each => |e| blk: {
for (e.children) |child| {
if (nodeHasOutput(child)) break :blk true;
}
break :blk false;
},
.document => |d| blk: {
for (d.nodes) |child| {
if (nodeHasOutput(child)) break :blk true;
}
break :blk false;
},
else => false,
};
}
fn nodeHasDynamic(node: ast.Node) bool {
return switch (node) {
.element => |e| blk: {
if (e.buffered_code != null) break :blk true;
if (e.inline_text) |segs| {
for (segs) |seg| {
if (seg != .literal) break :blk true;
}
}
for (e.children) |child| {
if (nodeHasDynamic(child)) break :blk true;
}
break :blk false;
},
.text => |t| blk: {
for (t.segments) |seg| {
if (seg != .literal) break :blk true;
}
break :blk false;
},
.conditional, .each => true,
.document => |d| blk: {
for (d.nodes) |child| {
if (nodeHasDynamic(child)) break :blk true;
}
break :blk false;
},
else => false,
};
}
const Compiler = struct {
allocator: std.mem.Allocator,
writer: std.ArrayListUnmanaged(u8).Writer,
buf: std.ArrayListUnmanaged(u8), // Buffer for merging static strings
depth: usize,
loop_vars: std.ArrayListUnmanaged([]const u8), // Track loop variable names
fn init(allocator: std.mem.Allocator, writer: std.ArrayListUnmanaged(u8).Writer) Compiler {
return .{
.allocator = allocator,
.writer = writer,
.buf = .{},
.depth = 1,
.loop_vars = .{},
};
}
fn flush(self: *Compiler) !void {
if (self.buf.items.len > 0) {
try self.writeIndent();
try self.writer.writeAll("try o.appendSlice(a, \"");
try self.writer.writeAll(self.buf.items);
try self.writer.writeAll("\");\n");
self.buf.items.len = 0;
}
}
fn flushAsReturn(self: *Compiler) !void {
// For static-only templates - return string literal directly
try self.writer.writeAll("\"");
try self.writer.writeAll(self.buf.items);
try self.writer.writeAll("\";\n");
self.buf.items.len = 0;
}
fn appendStatic(self: *Compiler, s: []const u8) !void {
for (s) |c| {
const escaped: []const u8 = switch (c) {
'\\' => "\\\\",
'"' => "\\\"",
'\n' => "\\n",
'\r' => "\\r",
'\t' => "\\t",
else => &[_]u8{c},
};
try self.buf.appendSlice(self.allocator, escaped);
}
}
fn writeIndent(self: *Compiler) !void {
for (0..self.depth) |_| try self.writer.writeAll(" ");
}
fn emitNode(self: *Compiler, node: ast.Node) anyerror!void {
switch (node) {
.doctype => |dt| {
if (std.mem.eql(u8, dt.value, "html")) {
try self.appendStatic("<!DOCTYPE html>");
} else {
try self.appendStatic("<!DOCTYPE ");
try self.appendStatic(dt.value);
try self.appendStatic(">");
}
},
.element => |e| try self.emitElement(e),
.text => |t| try self.emitText(t.segments),
.raw_text => |r| try self.appendStatic(r.content),
.conditional => |c| try self.emitConditional(c),
.each => |e| try self.emitEach(e),
.comment => |c| if (c.rendered) {
try self.appendStatic("<!-- ");
try self.appendStatic(c.content);
try self.appendStatic(" -->");
},
.document => |dc| for (dc.nodes) |child| try self.emitNode(child),
else => {},
}
}
fn emitElement(self: *Compiler, e: ast.Element) anyerror!void {
const is_void = isVoidElement(e.tag) or e.self_closing;
// Open tag
try self.appendStatic("<");
try self.appendStatic(e.tag);
if (e.id) |id| {
try self.appendStatic(" id=\"");
try self.appendStatic(id);
try self.appendStatic("\"");
}
if (e.classes.len > 0) {
try self.appendStatic(" class=\"");
for (e.classes, 0..) |cls, i| {
if (i > 0) try self.appendStatic(" ");
try self.appendStatic(cls);
}
try self.appendStatic("\"");
}
for (e.attributes) |attr| {
if (attr.value) |v| {
if (v.len >= 2 and (v[0] == '"' or v[0] == '\'')) {
try self.appendStatic(" ");
try self.appendStatic(attr.name);
try self.appendStatic("=\"");
try self.appendStatic(v[1 .. v.len - 1]);
try self.appendStatic("\"");
}
} else {
try self.appendStatic(" ");
try self.appendStatic(attr.name);
try self.appendStatic("=\"");
try self.appendStatic(attr.name);
try self.appendStatic("\"");
}
}
if (is_void) {
try self.appendStatic(" />");
return;
}
try self.appendStatic(">");
if (e.inline_text) |segs| {
try self.emitText(segs);
}
if (e.buffered_code) |bc| {
try self.emitExpr(bc.expression, bc.escaped);
}
for (e.children) |child| {
try self.emitNode(child);
}
try self.appendStatic("</");
try self.appendStatic(e.tag);
try self.appendStatic(">");
}
fn emitText(self: *Compiler, segs: []const ast.TextSegment) anyerror!void {
for (segs) |seg| {
switch (seg) {
.literal => |lit| try self.appendStatic(lit),
.interp_escaped => |expr| try self.emitExpr(expr, true),
.interp_unescaped => |expr| try self.emitExpr(expr, false),
.interp_tag => |t| try self.emitInlineTag(t),
}
}
}
fn emitInlineTag(self: *Compiler, t: ast.InlineTag) anyerror!void {
try self.appendStatic("<");
try self.appendStatic(t.tag);
if (t.id) |id| {
try self.appendStatic(" id=\"");
try self.appendStatic(id);
try self.appendStatic("\"");
}
if (t.classes.len > 0) {
try self.appendStatic(" class=\"");
for (t.classes, 0..) |cls, i| {
if (i > 0) try self.appendStatic(" ");
try self.appendStatic(cls);
}
try self.appendStatic("\"");
}
try self.appendStatic(">");
try self.emitText(t.text_segments);
try self.appendStatic("</");
try self.appendStatic(t.tag);
try self.appendStatic(">");
}
fn emitExpr(self: *Compiler, expr: []const u8, escaped: bool) !void {
try self.flush(); // Dynamic content - flush static buffer first
try self.writeIndent();
// Generate the accessor expression
var accessor_buf: [512]u8 = undefined;
const accessor = self.buildAccessor(expr, &accessor_buf);
// Use strVal helper to handle type conversion
if (escaped) {
try self.writer.print("try esc(&o, a, strVal({s}));\n", .{accessor});
} else {
try self.writer.print("try o.appendSlice(a, strVal({s}));\n", .{accessor});
}
}
fn isLoopVar(self: *Compiler, name: []const u8) bool {
for (self.loop_vars.items) |v| {
if (std.mem.eql(u8, v, name)) return true;
}
return false;
}
fn buildAccessor(self: *Compiler, expr: []const u8, buf: []u8) []const u8 {
// Handle nested field access like friend.name, subFriend.id
if (std.mem.indexOfScalar(u8, expr, '.')) |dot| {
const base = expr[0..dot];
const rest = expr[dot + 1 ..];
// For loop variables like friend.name, access directly
return std.fmt.bufPrint(buf, "{s}.{s}", .{ base, rest }) catch expr;
} else {
// Check if it's a loop variable (like color, item, tag)
if (self.isLoopVar(expr)) {
return expr;
}
// For top-level like "name", access from d
return std.fmt.bufPrint(buf, "@field(d, \"{s}\")", .{expr}) catch expr;
}
}
fn emitConditional(self: *Compiler, c: ast.Conditional) anyerror!void {
try self.flush();
for (c.branches, 0..) |br, i| {
try self.writeIndent();
if (i == 0) {
if (br.is_unless) {
try self.writer.writeAll("if (!");
} else {
try self.writer.writeAll("if (");
}
try self.emitCondition(br.condition orelse "true");
try self.writer.writeAll(") {\n");
} else if (br.condition) |cond| {
try self.writer.writeAll("} else if (");
try self.emitCondition(cond);
try self.writer.writeAll(") {\n");
} else {
try self.writer.writeAll("} else {\n");
}
self.depth += 1;
for (br.children) |child| try self.emitNode(child);
try self.flush();
self.depth -= 1;
}
try self.writeIndent();
try self.writer.writeAll("}\n");
}
fn emitCondition(self: *Compiler, cond: []const u8) !void {
// Handle string equality: status == "closed" -> std.mem.eql(u8, status, "closed")
if (std.mem.indexOf(u8, cond, " == \"")) |eq_pos| {
const lhs = std.mem.trim(u8, cond[0..eq_pos], " ");
const rhs_start = eq_pos + 5; // skip ' == "'
if (std.mem.indexOfScalar(u8, cond[rhs_start..], '"')) |rhs_end| {
const rhs = cond[rhs_start .. rhs_start + rhs_end];
try self.writer.print("std.mem.eql(u8, {s}, \"{s}\")", .{ lhs, rhs });
return;
}
}
// Handle string inequality: status != "closed"
if (std.mem.indexOf(u8, cond, " != \"")) |eq_pos| {
const lhs = std.mem.trim(u8, cond[0..eq_pos], " ");
const rhs_start = eq_pos + 5;
if (std.mem.indexOfScalar(u8, cond[rhs_start..], '"')) |rhs_end| {
const rhs = cond[rhs_start .. rhs_start + rhs_end];
try self.writer.print("!std.mem.eql(u8, {s}, \"{s}\")", .{ lhs, rhs });
return;
}
}
// Regular field access
if (std.mem.indexOfScalar(u8, cond, '.')) |_| {
try self.writer.print("truthy({s})", .{cond});
} else {
try self.writer.print("truthy(@field(d, \"{s}\"))", .{cond});
}
}
fn emitEach(self: *Compiler, e: ast.Each) anyerror!void {
try self.flush();
try self.writeIndent();
// Track this loop variable
try self.loop_vars.append(self.allocator, e.value_name);
// Generate the for loop - handle optional collections with orelse
if (std.mem.indexOfScalar(u8, e.collection, '.')) |dot| {
const base = e.collection[0..dot];
const field = e.collection[dot + 1 ..];
// Use orelse to handle optional slices
try self.writer.print("for (if (@typeInfo(@TypeOf({s}.{s})) == .optional) ({s}.{s} orelse &.{{}}) else {s}.{s}) |{s}", .{ base, field, base, field, base, field, e.value_name });
} else {
try self.writer.print("for (@field(d, \"{s}\")) |{s}", .{ e.collection, e.value_name });
}
if (e.index_name) |idx| {
try self.writer.print(", {s}", .{idx});
}
try self.writer.writeAll("| {\n");
self.depth += 1;
for (e.children) |child| {
try self.emitNode(child);
}
try self.flush();
self.depth -= 1;
try self.writeIndent();
try self.writer.writeAll("}\n");
// Pop loop variable
_ = self.loop_vars.pop();
}
};
fn isVoidElement(tag: []const u8) bool {
const voids = std.StaticStringMap(void).initComptime(.{
.{ "area", {} }, .{ "base", {} }, .{ "br", {} }, .{ "col", {} },
.{ "embed", {} }, .{ "hr", {} }, .{ "img", {} }, .{ "input", {} },
.{ "link", {} }, .{ "meta", {} }, .{ "param", {} }, .{ "source", {} },
.{ "track", {} }, .{ "wbr", {} },
});
return voids.has(tag);
}

472
src/compiler.zig Normal file
View File

@@ -0,0 +1,472 @@
//! Pugz Compiler - Compiles Pug templates to efficient Zig functions.
//!
//! Generates Zig source code that can be @import'd and called directly,
//! avoiding AST interpretation overhead entirely.
const std = @import("std");
const ast = @import("ast.zig");
const Lexer = @import("lexer.zig").Lexer;
const Parser = @import("parser.zig").Parser;
/// Compiles a Pug source string to a Zig function.
pub fn compileSource(allocator: std.mem.Allocator, name: []const u8, source: []const u8) ![]u8 {
var lexer = Lexer.init(allocator, source);
defer lexer.deinit();
const tokens = try lexer.tokenize();
var parser = Parser.init(allocator, tokens);
const doc = try parser.parse();
return compileDoc(allocator, name, doc);
}
/// Compiles an AST Document to a Zig function.
pub fn compileDoc(allocator: std.mem.Allocator, name: []const u8, doc: ast.Document) ![]u8 {
var c = Compiler.init(allocator);
defer c.deinit();
return c.compile(name, doc);
}
const Compiler = struct {
alloc: std.mem.Allocator,
out: std.ArrayListUnmanaged(u8),
depth: u8,
fn init(allocator: std.mem.Allocator) Compiler {
return .{
.alloc = allocator,
.out = .{},
.depth = 0,
};
}
fn deinit(self: *Compiler) void {
self.out.deinit(self.alloc);
}
fn compile(self: *Compiler, name: []const u8, doc: ast.Document) ![]u8 {
// Header
try self.w(
\\const std = @import("std");
\\
\\/// HTML escape lookup table
\\const esc_table = blk: {
\\ var t: [256]?[]const u8 = .{null} ** 256;
\\ t['&'] = "&amp;";
\\ t['<'] = "&lt;";
\\ t['>'] = "&gt;";
\\ t['"'] = "&quot;";
\\ t['\''] = "&#x27;";
\\ break :blk t;
\\};
\\
\\fn esc(out: *std.ArrayList(u8), s: []const u8) !void {
\\ var i: usize = 0;
\\ for (s, 0..) |c, j| {
\\ if (esc_table[c]) |e| {
\\ if (j > i) try out.appendSlice(s[i..j]);
\\ try out.appendSlice(e);
\\ i = j + 1;
\\ }
\\ }
\\ if (i < s.len) try out.appendSlice(s[i..]);
\\}
\\
\\fn toStr(v: anytype) []const u8 {
\\ const T = @TypeOf(v);
\\ if (T == []const u8) return v;
\\ if (@typeInfo(T) == .optional) {
\\ if (v) |inner| return toStr(inner);
\\ return "";
\\ }
\\ return "";
\\}
\\
\\
);
// Function signature
try self.w("pub fn ");
try self.w(name);
try self.w("(out: *std.ArrayList(u8), data: anytype) !void {\n");
self.depth = 1;
// Body
for (doc.nodes) |n| {
try self.node(n);
}
try self.w("}\n");
return try self.alloc.dupe(u8, self.out.items);
}
fn node(self: *Compiler, n: ast.Node) anyerror!void {
switch (n) {
.doctype => |d| try self.doctype(d),
.element => |e| try self.element(e),
.text => |t| try self.text(t.segments),
.conditional => |c| try self.conditional(c),
.each => |e| try self.each(e),
.raw_text => |r| try self.raw(r.content),
.comment => |c| if (c.rendered) try self.comment(c),
.code => |c| try self.code(c),
.document => |d| for (d.nodes) |child| try self.node(child),
.mixin_def, .mixin_call, .mixin_block, .@"while", .case, .block, .include, .extends => {},
}
}
fn doctype(self: *Compiler, d: ast.Doctype) !void {
try self.indent();
if (std.mem.eql(u8, d.value, "html")) {
try self.w("try out.appendSlice(\"<!DOCTYPE html>\");\n");
} else {
try self.w("try out.appendSlice(\"<!DOCTYPE ");
try self.wEsc(d.value);
try self.w(">\");\n");
}
}
fn element(self: *Compiler, e: ast.Element) anyerror!void {
const is_void = isVoid(e.tag) or e.self_closing;
// Open tag
try self.indent();
try self.w("try out.appendSlice(\"<");
try self.w(e.tag);
// ID
if (e.id) |id| {
try self.w(" id=\\\"");
try self.wEsc(id);
try self.w("\\\"");
}
// Classes
if (e.classes.len > 0) {
try self.w(" class=\\\"");
for (e.classes, 0..) |cls, i| {
if (i > 0) try self.w(" ");
try self.wEsc(cls);
}
try self.w("\\\"");
}
// Static attributes (close the appendSlice, handle dynamic separately)
var has_dynamic = false;
for (e.attributes) |attr| {
if (attr.value) |v| {
if (isDynamic(v)) {
has_dynamic = true;
continue;
}
try self.w(" ");
try self.w(attr.name);
try self.w("=\\\"");
try self.wEsc(stripQuotes(v));
try self.w("\\\"");
} else {
try self.w(" ");
try self.w(attr.name);
try self.w("=\\\"");
try self.w(attr.name);
try self.w("\\\"");
}
}
if (is_void and !has_dynamic) {
try self.w(" />\");\n");
return;
}
if (!has_dynamic and e.inline_text == null and e.buffered_code == null) {
try self.w(">\");\n");
} else {
try self.w("\");\n");
}
// Dynamic attributes
for (e.attributes) |attr| {
if (attr.value) |v| {
if (isDynamic(v)) {
try self.indent();
try self.w("try out.appendSlice(\" ");
try self.w(attr.name);
try self.w("=\\\"\");\n");
try self.indent();
try self.expr(v, attr.escaped);
try self.indent();
try self.w("try out.appendSlice(\"\\\"\");\n");
}
}
}
if (has_dynamic or e.inline_text != null or e.buffered_code != null) {
try self.indent();
if (is_void) {
try self.w("try out.appendSlice(\" />\");\n");
return;
}
try self.w("try out.appendSlice(\">\");\n");
}
// Inline text
if (e.inline_text) |segs| {
try self.text(segs);
}
// Buffered code (p= expr)
if (e.buffered_code) |bc| {
try self.indent();
try self.expr(bc.expression, bc.escaped);
}
// Children
self.depth += 1;
for (e.children) |child| {
try self.node(child);
}
self.depth -= 1;
// Close tag
try self.indent();
try self.w("try out.appendSlice(\"</");
try self.w(e.tag);
try self.w(">\");\n");
}
fn text(self: *Compiler, segs: []const ast.TextSegment) anyerror!void {
for (segs) |seg| {
switch (seg) {
.literal => |lit| {
try self.indent();
try self.w("try out.appendSlice(\"");
try self.wEsc(lit);
try self.w("\");\n");
},
.interp_escaped => |e| {
try self.indent();
try self.expr(e, true);
},
.interp_unescaped => |e| {
try self.indent();
try self.expr(e, false);
},
.interp_tag => |t| try self.inlineTag(t),
}
}
}
fn inlineTag(self: *Compiler, t: ast.InlineTag) anyerror!void {
try self.indent();
try self.w("try out.appendSlice(\"<");
try self.w(t.tag);
if (t.id) |id| {
try self.w(" id=\\\"");
try self.wEsc(id);
try self.w("\\\"");
}
if (t.classes.len > 0) {
try self.w(" class=\\\"");
for (t.classes, 0..) |cls, i| {
if (i > 0) try self.w(" ");
try self.wEsc(cls);
}
try self.w("\\\"");
}
try self.w(">\");\n");
try self.text(t.text_segments);
try self.indent();
try self.w("try out.appendSlice(\"</");
try self.w(t.tag);
try self.w(">\");\n");
}
fn conditional(self: *Compiler, c: ast.Conditional) anyerror!void {
for (c.branches, 0..) |br, i| {
try self.indent();
if (i == 0) {
if (br.is_unless) {
try self.w("if (!");
} else {
try self.w("if (");
}
try self.cond(br.condition orelse "true");
try self.w(") {\n");
} else if (br.condition) |cnd| {
try self.w("} else if (");
try self.cond(cnd);
try self.w(") {\n");
} else {
try self.w("} else {\n");
}
self.depth += 1;
for (br.children) |child| try self.node(child);
self.depth -= 1;
}
try self.indent();
try self.w("}\n");
}
fn cond(self: *Compiler, c: []const u8) !void {
// Check for field access: convert "field" to "@hasField(...) and data.field"
// and "obj.field" to "obj.field" (assuming obj is a loop var)
if (std.mem.indexOfScalar(u8, c, '.')) |_| {
try self.w(c);
} else {
try self.w("@hasField(@TypeOf(data), \"");
try self.w(c);
try self.w("\") and @field(data, \"");
try self.w(c);
try self.w("\") != null");
}
}
fn each(self: *Compiler, e: ast.Each) anyerror!void {
// Parse collection - could be "items" or "obj.items"
const col = e.collection;
try self.indent();
if (std.mem.indexOfScalar(u8, col, '.')) |dot| {
// Nested: for (parent.field) |item|
try self.w("for (");
try self.w(col[0..dot]);
try self.w(".");
try self.w(col[dot + 1 ..]);
try self.w(") |");
} else {
// Top-level: for (data.field) |item|
try self.w("if (@hasField(@TypeOf(data), \"");
try self.w(col);
try self.w("\")) {\n");
self.depth += 1;
try self.indent();
try self.w("for (@field(data, \"");
try self.w(col);
try self.w("\")) |");
}
try self.w(e.value_name);
if (e.index_name) |idx| {
try self.w(", ");
try self.w(idx);
}
try self.w("| {\n");
self.depth += 1;
for (e.children) |child| try self.node(child);
self.depth -= 1;
try self.indent();
try self.w("}\n");
// Close the hasField block for top-level
if (std.mem.indexOfScalar(u8, col, '.') == null) {
self.depth -= 1;
try self.indent();
try self.w("}\n");
}
}
fn code(self: *Compiler, c: ast.Code) !void {
try self.indent();
try self.expr(c.expression, c.escaped);
}
fn expr(self: *Compiler, e: []const u8, escaped: bool) !void {
// Parse: "name" (data field), "item.name" (loop var field)
if (std.mem.indexOfScalar(u8, e, '.')) |dot| {
const base = e[0..dot];
const field = e[dot + 1 ..];
if (escaped) {
try self.w("try esc(out, toStr(");
try self.w(base);
try self.w(".");
try self.w(field);
try self.w("));\n");
} else {
try self.w("try out.appendSlice(toStr(");
try self.w(base);
try self.w(".");
try self.w(field);
try self.w("));\n");
}
} else {
if (escaped) {
try self.w("try esc(out, toStr(@field(data, \"");
try self.w(e);
try self.w("\")));\n");
} else {
try self.w("try out.appendSlice(toStr(@field(data, \"");
try self.w(e);
try self.w("\")));\n");
}
}
}
fn raw(self: *Compiler, content: []const u8) !void {
try self.indent();
try self.w("try out.appendSlice(\"");
try self.wEsc(content);
try self.w("\");\n");
}
fn comment(self: *Compiler, c: ast.Comment) !void {
try self.indent();
try self.w("try out.appendSlice(\"<!-- ");
try self.wEsc(c.content);
try self.w(" -->\");\n");
}
// Helpers
fn indent(self: *Compiler) !void {
for (0..self.depth) |_| try self.out.appendSlice(self.alloc, " ");
}
fn w(self: *Compiler, s: []const u8) !void {
try self.out.appendSlice(self.alloc, s);
}
fn wEsc(self: *Compiler, s: []const u8) !void {
for (s) |c| {
switch (c) {
'\\' => try self.out.appendSlice(self.alloc, "\\\\"),
'"' => try self.out.appendSlice(self.alloc, "\\\""),
'\n' => try self.out.appendSlice(self.alloc, "\\n"),
'\r' => try self.out.appendSlice(self.alloc, "\\r"),
'\t' => try self.out.appendSlice(self.alloc, "\\t"),
else => try self.out.append(self.alloc, c),
}
}
}
};
fn isDynamic(v: []const u8) bool {
if (v.len < 2) return true;
return v[0] != '"' and v[0] != '\'';
}
fn stripQuotes(v: []const u8) []const u8 {
if (v.len >= 2 and (v[0] == '"' or v[0] == '\'')) {
return v[1 .. v.len - 1];
}
return v;
}
fn isVoid(tag: []const u8) bool {
const voids = std.StaticStringMap(void).initComptime(.{
.{ "area", {} }, .{ "base", {} }, .{ "br", {} }, .{ "col", {} },
.{ "embed", {} }, .{ "hr", {} }, .{ "img", {} }, .{ "input", {} },
.{ "link", {} }, .{ "meta", {} }, .{ "param", {} }, .{ "source", {} },
.{ "track", {} }, .{ "wbr", {} },
});
return voids.has(tag);
}
test "compile simple template" {
const allocator = std.testing.allocator;
const source = "p Hello";
const code = try compileSource(allocator, "simple", source);
defer allocator.free(code);
std.debug.print("\n{s}\n", .{code});
}

View File

@@ -1,148 +0,0 @@
//! Pugz Template Inheritance Demo
//!
//! A web application demonstrating Pug-style template inheritance
//! using the Pugz ViewEngine with http.zig server.
//!
//! Routes:
//! GET / - Home page (layout.pug)
//! GET /page-a - Page A with custom scripts and content
//! GET /page-b - Page B with sub-layout
//! GET /append - Page with block append
//! GET /append-opt - Page with optional block syntax
const std = @import("std");
const httpz = @import("httpz");
const pugz = @import("pugz");
const Allocator = std.mem.Allocator;
/// Application state shared across all requests
const App = struct {
allocator: Allocator,
view: pugz.ViewEngine,
pub fn init(allocator: Allocator) App {
return .{
.allocator = allocator,
.view = pugz.ViewEngine.init(.{
.views_dir = "src/examples/demo/views",
}),
};
}
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer if (gpa.deinit() == .leak) @panic("leak");
const allocator = gpa.allocator();
// Initialize view engine once at startup
var app = App.init(allocator);
const port = 8080;
var server = try httpz.Server(*App).init(allocator, .{ .port = port }, &app);
defer server.deinit();
var router = try server.router(.{});
// Routes
router.get("/", index, .{});
router.get("/page-a", pageA, .{});
router.get("/page-b", pageB, .{});
router.get("/append", pageAppend, .{});
router.get("/append-opt", pageAppendOptional, .{});
std.debug.print(
\\
\\Pugz Template Inheritance Demo
\\==============================
\\Server running at http://localhost:{d}
\\
\\Routes:
\\ GET / - Home page (base layout)
\\ GET /page-a - Page with custom scripts and content blocks
\\ GET /page-b - Page with sub-layout inheritance
\\ GET /append - Page with block append
\\ GET /append-opt - Page with optional block keyword
\\
\\Press Ctrl+C to stop.
\\
, .{port});
try server.listen();
}
/// Handler for GET /
fn index(app: *App, _: *httpz.Request, res: *httpz.Response) !void {
// Use res.arena - memory is automatically freed after response is sent
const html = app.view.render(res.arena, "index", .{
.title = "Home",
.authenticated = true,
}) catch |err| {
res.status = 500;
res.body = @errorName(err);
return;
};
res.content_type = .HTML;
res.body = html;
}
/// Handler for GET /page-a - demonstrates extends and block override
fn pageA(app: *App, _: *httpz.Request, res: *httpz.Response) !void {
const html = app.view.render(res.arena, "page-a", .{
.title = "Page A - Pets",
.items = &[_][]const u8{ "A", "B", "C" },
.n = 0,
}) catch |err| {
res.status = 500;
res.body = @errorName(err);
return;
};
res.content_type = .HTML;
res.body = html;
}
/// Handler for GET /page-b - demonstrates sub-layout inheritance
fn pageB(app: *App, _: *httpz.Request, res: *httpz.Response) !void {
const html = app.view.render(res.arena, "page-b", .{
.title = "Page B - Sub Layout",
}) catch |err| {
res.status = 500;
res.body = @errorName(err);
return;
};
res.content_type = .HTML;
res.body = html;
}
/// Handler for GET /append - demonstrates block append
fn pageAppend(app: *App, _: *httpz.Request, res: *httpz.Response) !void {
const html = app.view.render(res.arena, "page-append", .{
.title = "Page Append",
}) catch |err| {
res.status = 500;
res.body = @errorName(err);
return;
};
res.content_type = .HTML;
res.body = html;
}
/// Handler for GET /append-opt - demonstrates optional block keyword
fn pageAppendOptional(app: *App, _: *httpz.Request, res: *httpz.Response) !void {
const html = app.view.render(res.arena, "page-appen-optional-blk", .{
.title = "Page Append Optional",
}) catch |err| {
res.status = 500;
res.body = @errorName(err);
return;
};
res.content_type = .HTML;
res.body = html;
}

View File

@@ -1,15 +0,0 @@
doctype html
html
head
title hello
body
p some thing
| ballah
| ballah
+btn("click me ", "secondary")
br
a(href='//google.com' target="_blank") Google 1
br
a(class='button' href='//google.com' target="_blank") Google 2
br
a(class='button', href='//google.com' target="_blank") Google 3

View File

@@ -1,7 +0,0 @@
html
head
block head
script(src='/vendor/jquery.js')
script(src='/vendor/caustic.js')
body
block content

View File

@@ -1,10 +0,0 @@
html
head
title My Site - #{title}
block scripts
script(src='/jquery.js')
body
block content
block foot
#footer
p some footer content

View File

@@ -1,5 +0,0 @@
mixin btn(text, type="primary")
button(class="btn btn-" + type)= text
mixin btn-link(href, text)
a.btn.btn-link(href=href)= text

View File

@@ -1,11 +0,0 @@
mixin card(title)
.card
.card-header
h3= title
.card-body
block
mixin card-simple(title, body)
.card
h3= title
p= body

View File

@@ -1,15 +0,0 @@
extends layout.pug
block scripts
script(src='/jquery.js')
script(src='/pets.js')
block content
h1= title
p Welcome to the pets page!
ul
li Cat
li Dog
ul
each val in items
li= val

View File

@@ -1,5 +0,0 @@
extends layout
append head
script(src='/vendor/three.js')
script(src='/game.js')

View File

@@ -1,11 +0,0 @@
extends layout-2.pug
block append head
script(src='/vendor/three.js')
script(src='/game.js')
block content
p
| cheks manually the head section
br
| hello there

View File

@@ -1,9 +0,0 @@
extends sub-layout.pug
block content
.sidebar
block sidebar
p nothing
.primary
block primary
p nothing

View File

@@ -1 +0,0 @@
p= petName

View File

@@ -1,9 +0,0 @@
extends layout.pug
block content
.sidebar
block sidebar
p nothing
.primary
block primary
p nothing

View File

@@ -1174,8 +1174,12 @@ pub const Lexer = struct {
if (self.peek() != ' ') return;
const next = self.peekAt(1);
const next2 = self.peekAt(2);
// Don't consume if followed by another selector, attribute, or special syntax
if (next == '.' or next == '#' or next == '(' or next == '=' or next == ':' or
// BUT: #{...} and #[...] are interpolation, not ID selectors
const is_id_selector = next == '#' and next2 != '{' and next2 != '[';
if (next == '.' or is_id_selector or next == '(' or next == '=' or next == ':' or
next == '\n' or next == '\r' or next == 0)
{
return;

View File

@@ -55,6 +55,11 @@ pub const renderTemplate = runtime.renderTemplate;
// High-level API
pub const ViewEngine = view_engine.ViewEngine;
pub const CompiledTemplate = view_engine.CompiledTemplate;
// Build-time template compilation
pub const build_templates = @import("build_templates.zig");
pub const compileTemplates = build_templates.compileTemplates;
test {
_ = @import("std").testing.refAllDecls(@This());

View File

@@ -46,18 +46,45 @@ pub const Value = union(enum) {
object: std.StringHashMapUnmanaged(Value),
/// Returns the value as a string for output.
/// For integers, uses pre-computed strings for small values to avoid allocation.
pub fn toString(self: Value, allocator: std.mem.Allocator) ![]const u8 {
// Fast path: strings are most common in templates (branch hint)
if (self == .string) {
@branchHint(.likely);
return self.string;
}
return switch (self) {
.string => unreachable, // handled above
.null => "",
.bool => |b| if (b) "true" else "false",
.int => |i| try std.fmt.allocPrint(allocator, "{d}", .{i}),
.int => |i| blk: {
// Fast path for common small integers (0-99)
if (i >= 0 and i < 100) {
break :blk small_int_strings[@intCast(i)];
}
// Allocate for larger integers
break :blk try std.fmt.allocPrint(allocator, "{d}", .{i});
},
.float => |f| try std.fmt.allocPrint(allocator, "{d}", .{f}),
.string => |s| s,
.array => "[Array]",
.object => "[Object]",
};
}
/// Pre-computed strings for small integers 0-99 (common in loops)
const small_int_strings = [_][]const u8{
"0", "1", "2", "3", "4", "5", "6", "7", "8", "9",
"10", "11", "12", "13", "14", "15", "16", "17", "18", "19",
"20", "21", "22", "23", "24", "25", "26", "27", "28", "29",
"30", "31", "32", "33", "34", "35", "36", "37", "38", "39",
"40", "41", "42", "43", "44", "45", "46", "47", "48", "49",
"50", "51", "52", "53", "54", "55", "56", "57", "58", "59",
"60", "61", "62", "63", "64", "65", "66", "67", "68", "69",
"70", "71", "72", "73", "74", "75", "76", "77", "78", "79",
"80", "81", "82", "83", "84", "85", "86", "87", "88", "89",
"90", "91", "92", "93", "94", "95", "96", "97", "98", "99",
};
/// Returns the value as a boolean for conditionals.
pub fn isTruthy(self: Value) bool {
return switch (self) {
@@ -155,10 +182,31 @@ pub const Context = struct {
try current.put(self.allocator, name, value);
}
/// Gets or creates a slot for a variable, returning a pointer to the value.
/// Use this for loop variables that are updated repeatedly.
pub fn getOrPutPtr(self: *Context, name: []const u8) !*Value {
if (self.scope_depth == 0) {
try self.pushScope();
}
const current = &self.scopes.items[self.scope_depth - 1];
const gop = try current.getOrPut(self.allocator, name);
if (!gop.found_existing) {
gop.value_ptr.* = Value.null;
}
return gop.value_ptr;
}
/// Gets a variable, searching from innermost to outermost scope.
pub fn get(self: *Context, name: []const u8) ?Value {
// Search from innermost to outermost scope
var i = self.scope_depth;
// Fast path: most lookups are in the innermost scope
if (self.scope_depth > 0) {
@branchHint(.likely);
if (self.scopes.items[self.scope_depth - 1].get(name)) |value| {
return value;
}
}
// Search remaining scopes (less common)
var i = self.scope_depth -| 1;
while (i > 0) {
i -= 1;
if (self.scopes.items[i].get(name)) |value| {
@@ -249,7 +297,8 @@ pub const Runtime = struct {
/// Renders the document and returns the HTML output.
pub fn render(self: *Runtime, doc: ast.Document) Error![]const u8 {
try self.output.ensureTotalCapacity(self.allocator, 1024);
// Pre-allocate buffer - 256KB handles most large templates without realloc
try self.output.ensureTotalCapacity(self.allocator, 256 * 1024);
// Handle template inheritance
if (doc.extends_path) |extends_path| {
@@ -600,11 +649,18 @@ pub const Runtime = struct {
try self.context.pushScope();
defer self.context.popScope();
// Get direct pointers to loop variables - avoids hash lookup per iteration
const value_ptr = try self.context.getOrPutPtr(each.value_name);
const index_ptr: ?*Value = if (each.index_name) |idx_name|
try self.context.getOrPutPtr(idx_name)
else
null;
for (items, 0..) |item, index| {
// Just overwrite the loop variable (no scope push/pop per iteration)
try self.context.set(each.value_name, item);
if (each.index_name) |idx_name| {
try self.context.set(idx_name, Value.integer(@intCast(index)));
// Direct pointer update - no hash lookup!
value_ptr.* = item;
if (index_ptr) |ptr| {
ptr.* = Value.integer(@intCast(index));
}
for (each.children) |child| {
@@ -624,19 +680,24 @@ pub const Runtime = struct {
try self.context.pushScope();
defer self.context.popScope();
// Get direct pointers to loop variables
const value_ptr = try self.context.getOrPutPtr(each.value_name);
const index_ptr: ?*Value = if (each.index_name) |idx_name|
try self.context.getOrPutPtr(idx_name)
else
null;
var iter = obj.iterator();
var index: usize = 0;
while (iter.next()) |entry| {
// Just overwrite the loop variable (no scope push/pop per iteration)
try self.context.set(each.value_name, entry.value_ptr.*);
if (each.index_name) |idx_name| {
try self.context.set(idx_name, Value.str(entry.key_ptr.*));
// Direct pointer update - no hash lookup!
value_ptr.* = entry.value_ptr.*;
if (index_ptr) |ptr| {
ptr.* = Value.str(entry.key_ptr.*);
}
for (each.children) |child| {
try self.visitNode(child);
}
index += 1;
}
},
else => {
@@ -1032,8 +1093,60 @@ pub const Runtime = struct {
// ─────────────────────────────────────────────────────────────────────────
/// Evaluates a simple expression (variable lookup or literal).
/// Optimized for common cases: simple variable names without operators.
fn evaluateExpression(self: *Runtime, expr: []const u8) Value {
const trimmed = std.mem.trim(u8, expr, " \t");
// Fast path: empty expression
if (expr.len == 0) return Value.null;
const first = expr[0];
// Ultra-fast path: identifier starting with a-z (most common case)
// Covers: friend, name, friend.name, friend.email, tag, etc.
if (first >= 'a' and first <= 'z') {
// Scan for operators - if none found, direct variable lookup
for (expr) |c| {
// Check for operators that require complex evaluation
if (c == '+' or c == '[' or c == '(' or c == '{' or c == ' ' or c == '\t') {
break;
}
} else {
// No operators found - direct variable lookup (most common path)
return self.lookupVariable(expr);
}
}
// Fast path: check if expression needs trimming
const last = expr[expr.len - 1];
const needs_trim = first == ' ' or first == '\t' or last == ' ' or last == '\t';
const trimmed = if (needs_trim) std.mem.trim(u8, expr, " \t") else expr;
if (trimmed.len == 0) return Value.null;
// Fast path: simple variable lookup (no special chars except dots)
// Most expressions in templates are just variable names like "name" or "friend.email"
const first_char = trimmed[0];
if (first_char != '"' and first_char != '\'' and first_char != '-' and
(first_char < '0' or first_char > '9'))
{
// Quick scan: if no special operators, go straight to variable lookup
var has_operator = false;
for (trimmed) |c| {
if (c == '+' or c == '[' or c == '(' or c == '{') {
has_operator = true;
break;
}
}
if (!has_operator) {
// Check for boolean/null literals
if (trimmed.len <= 5) {
if (std.mem.eql(u8, trimmed, "true")) return Value.boolean(true);
if (std.mem.eql(u8, trimmed, "false")) return Value.boolean(false);
if (std.mem.eql(u8, trimmed, "null")) return Value.null;
}
// Simple variable lookup
return self.lookupVariable(trimmed);
}
}
// Check for string concatenation with + operator
// e.g., "btn btn-" + type or "hello " + name + "!"
@@ -1053,8 +1166,8 @@ pub const Runtime = struct {
// Check for string literal
if (trimmed.len >= 2) {
if ((trimmed[0] == '"' and trimmed[trimmed.len - 1] == '"') or
(trimmed[0] == '\'' and trimmed[trimmed.len - 1] == '\''))
if ((first_char == '"' and trimmed[trimmed.len - 1] == '"') or
(first_char == '\'' and trimmed[trimmed.len - 1] == '\''))
{
return Value.str(trimmed[1 .. trimmed.len - 1]);
}
@@ -1065,7 +1178,7 @@ pub const Runtime = struct {
return Value.integer(i);
} else |_| {}
// Check for boolean literals
// Check for boolean literals (fallback for complex expressions)
if (std.mem.eql(u8, trimmed, "true")) return Value.boolean(true);
if (std.mem.eql(u8, trimmed, "false")) return Value.boolean(false);
if (std.mem.eql(u8, trimmed, "null")) return Value.null;
@@ -1113,21 +1226,47 @@ pub const Runtime = struct {
}
/// Looks up a variable with dot notation support.
/// Optimized for the common case of single property access (e.g., "friend.name").
fn lookupVariable(self: *Runtime, path: []const u8) Value {
var parts = std.mem.splitScalar(u8, path, '.');
const first = parts.first();
var current = self.context.get(first) orelse return Value.null;
while (parts.next()) |part| {
switch (current) {
.object => |obj| {
current = obj.get(part) orelse return Value.null;
},
else => return Value.null,
// Fast path: find first dot position
var dot_pos: ?usize = null;
for (path, 0..) |c, i| {
if (c == '.') {
dot_pos = i;
break;
}
}
if (dot_pos == null) {
// No dots - simple variable lookup
return self.context.get(path) orelse Value.null;
}
// Has dots - get base variable first
const base_name = path[0..dot_pos.?];
var current = self.context.get(base_name) orelse return Value.null;
// Property access loop - objects are most common
var pos = dot_pos.? + 1;
while (pos < path.len) {
// Find next dot or end
var end = pos;
while (end < path.len and path[end] != '.') {
end += 1;
}
const prop = path[pos..end];
// Most values are objects in property chains (branch hint)
if (current == .object) {
@branchHint(.likely);
current = current.object.get(prop) orelse return Value.null;
} else {
return Value.null;
}
pos = end + 1;
}
return current;
}
@@ -1335,34 +1474,81 @@ pub const Runtime = struct {
}
fn write(self: *Runtime, str: []const u8) Error!void {
try self.output.appendSlice(self.allocator, str);
// Use addManyAsSlice for potentially faster bulk copy
const dest = try self.output.addManyAsSlice(self.allocator, str.len);
@memcpy(dest, str);
}
fn writeEscaped(self: *Runtime, str: []const u8) Error!void {
var start: usize = 0;
// Fast path: use SIMD-friendly byte scan for escape characters
// Check if any escaping needed using a simple loop (compiler can vectorize)
var escape_needed: usize = str.len;
for (str, 0..) |c, i| {
const escape: ?[]const u8 = switch (c) {
'&' => "&amp;",
'<' => "&lt;",
'>' => "&gt;",
'"' => "&quot;",
'\'' => "&#x27;",
else => null,
};
if (escape) |esc| {
// Use a lookup instead of multiple comparisons
if (escape_table[c]) {
escape_needed = i;
break;
}
}
// No escaping needed - single fast write
if (escape_needed == str.len) {
const dest = try self.output.addManyAsSlice(self.allocator, str.len);
@memcpy(dest, str);
return;
}
// Write prefix that doesn't need escaping
if (escape_needed > 0) {
const dest = try self.output.addManyAsSlice(self.allocator, escape_needed);
@memcpy(dest, str[0..escape_needed]);
}
// Slow path: escape remaining characters
var start = escape_needed;
for (str[escape_needed..], escape_needed..) |c, i| {
if (escape_table[c]) {
// Write accumulated non-escaped chars first
if (i > start) {
try self.output.appendSlice(self.allocator, str[start..i]);
const chunk = str[start..i];
const dest = try self.output.addManyAsSlice(self.allocator, chunk.len);
@memcpy(dest, chunk);
}
try self.output.appendSlice(self.allocator, esc);
const esc = escape_strings[c];
const dest = try self.output.addManyAsSlice(self.allocator, esc.len);
@memcpy(dest, esc);
start = i + 1;
}
}
// Write remaining non-escaped chars
if (start < str.len) {
try self.output.appendSlice(self.allocator, str[start..]);
const chunk = str[start..];
const dest = try self.output.addManyAsSlice(self.allocator, chunk.len);
@memcpy(dest, chunk);
}
}
/// Lookup table for characters that need HTML escaping
const escape_table = blk: {
var table: [256]bool = [_]bool{false} ** 256;
table['&'] = true;
table['<'] = true;
table['>'] = true;
table['"'] = true;
table['\''] = true;
break :blk table;
};
/// Escape strings for each character
const escape_strings = blk: {
var strings: [256][]const u8 = [_][]const u8{""} ** 256;
strings['&'] = "&amp;";
strings['<'] = "&lt;";
strings['>'] = "&gt;";
strings['"'] = "&quot;";
strings['\''] = "&#x27;";
break :blk strings;
};
};
// ─────────────────────────────────────────────────────────────────────────────
@@ -1582,6 +1768,7 @@ pub fn render(allocator: std.mem.Allocator, doc: ast.Document, data: anytype) ![
}
/// Converts a Zig value to a runtime Value.
/// For best performance, use an arena allocator.
pub fn toValue(allocator: std.mem.Allocator, v: anytype) Value {
const T = @TypeOf(v);
@@ -1628,11 +1815,12 @@ pub fn toValue(allocator: std.mem.Allocator, v: anytype) Value {
return Value.null;
},
.@"struct" => |info| {
// Convert struct to object
// Convert struct to object - pre-allocate for known field count
var obj = std.StringHashMapUnmanaged(Value).empty;
obj.ensureTotalCapacity(allocator, info.fields.len) catch return Value.null;
inline for (info.fields) |field| {
const field_value = @field(v, field.name);
obj.put(allocator, field.name, toValue(allocator, field_value)) catch return Value.null;
obj.putAssumeCapacity(field.name, toValue(allocator, field_value));
}
return .{ .object = obj };
},

View File

@@ -14,6 +14,27 @@ test "Simple interpolation" {
);
}
test "Interpolation only as text" {
try expectOutput(
"h1.header #{header}",
.{ .header = "MyHeader" },
"<h1 class=\"header\">MyHeader</h1>",
);
}
test "Interpolation in each loop" {
try expectOutput(
\\ul.list
\\ each item in list
\\ li.item #{item}
, .{ .list = &[_][]const u8{ "a", "b" } },
\\<ul class="list">
\\ <li class="item">a</li>
\\ <li class="item">b</li>
\\</ul>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Test Case 2: Attributes with inline text
// ─────────────────────────────────────────────────────────────────────────────
@@ -737,4 +758,3 @@ test "Mixin with string concatenation in class" {
\\<button class="btn btn-secondary">Click me</button>
);
}

View File

@@ -4,12 +4,13 @@
//! - Views directory configuration
//! - Lazy-loading mixins from a mixins subdirectory (on-demand)
//! - Relative path resolution for includes and extends
//! - **Compiled templates** for maximum performance (parse once, render many)
//!
//! Mixins are resolved in the following order:
//! 1. Mixins defined in the same template file
//! 2. Mixins from the mixins directory (lazy-loaded when first called)
//!
//! Example:
//! ## Basic Usage
//! ```zig
//! const engine = ViewEngine.init(.{
//! .views_dir = "src/views",
@@ -24,6 +25,19 @@
//! const out = try engine.renderTpl(allocator, tpl, .{ .title = "Hello" });
//! defer allocator.free(out);
//! ```
//!
//! ## Compiled Templates (High Performance)
//! For maximum performance, compile templates once and render many times:
//! ```zig
//! // At startup: compile template (keeps AST in memory)
//! var compiled = try CompiledTemplate.init(gpa, "h1 Hello, #{name}!");
//! defer compiled.deinit();
//!
//! // Per request: render with arena (fast, zero parsing overhead)
//! var arena = std.heap.ArenaAllocator.init(gpa);
//! defer arena.deinit();
//! const html = try compiled.render(arena.allocator(), .{ .name = "World" });
//! ```
const std = @import("std");
const Lexer = @import("lexer.zig").Lexer;
@@ -172,6 +186,151 @@ pub const ViewEngine = struct {
}
};
// ─────────────────────────────────────────────────────────────────────────────
// CompiledTemplate - Parse once, render many times
// ─────────────────────────────────────────────────────────────────────────────
const ast = @import("ast.zig");
/// A pre-compiled template that can be rendered multiple times with different data.
/// This is the fastest way to render templates - parsing happens once at startup,
/// and each render only needs to evaluate the AST with new data.
///
/// Memory layout:
/// - The CompiledTemplate owns an arena that holds all AST nodes and source strings
/// - Call render() with a per-request arena allocator for output
/// - Call deinit() when the template is no longer needed
///
/// Example:
/// ```zig
/// // Compile once at startup
/// var tpl = try CompiledTemplate.init(gpa, "h1 Hello, #{name}!");
/// defer tpl.deinit();
///
/// // Render many times with different data
/// for (requests) |req| {
/// var arena = std.heap.ArenaAllocator.init(gpa);
/// defer arena.deinit();
/// const html = try tpl.render(arena.allocator(), .{ .name = req.name });
/// // send html...
/// }
/// ```
pub const CompiledTemplate = struct {
/// Arena holding all compiled template data (AST, source slices)
arena: std.heap.ArenaAllocator,
/// The parsed document AST
doc: ast.Document,
/// Runtime options
options: RenderOptions,
pub const RenderOptions = struct {
pretty: bool = true,
base_dir: []const u8 = ".",
mixins_dir: []const u8 = "",
};
/// Compiles a template string into a reusable CompiledTemplate.
/// The backing_allocator is used for the internal arena that holds the AST.
pub fn init(backing_allocator: std.mem.Allocator, source: []const u8) !CompiledTemplate {
return initWithOptions(backing_allocator, source, .{});
}
/// Compiles a template with custom options.
pub fn initWithOptions(backing_allocator: std.mem.Allocator, source: []const u8, options: RenderOptions) !CompiledTemplate {
var arena = std.heap.ArenaAllocator.init(backing_allocator);
errdefer arena.deinit();
const alloc = arena.allocator();
// Copy source into arena (AST slices point into it)
const owned_source = try alloc.dupe(u8, source);
// Tokenize
var lexer = Lexer.init(alloc, owned_source);
// Don't deinit lexer - arena owns all memory
const tokens = lexer.tokenize() catch return ViewEngineError.ParseError;
// Parse
var parser = Parser.init(alloc, tokens);
const doc = parser.parse() catch return ViewEngineError.ParseError;
return .{
.arena = arena,
.doc = doc,
.options = options,
};
}
/// Compiles a template from a file.
pub fn initFromFile(backing_allocator: std.mem.Allocator, path: []const u8, options: RenderOptions) !CompiledTemplate {
const source = std.fs.cwd().readFileAlloc(backing_allocator, path, 5 * 1024 * 1024) catch {
return ViewEngineError.TemplateNotFound;
};
defer backing_allocator.free(source);
return initWithOptions(backing_allocator, source, options);
}
/// Releases all memory used by the compiled template.
pub fn deinit(self: *CompiledTemplate) void {
self.arena.deinit();
}
/// Renders the compiled template with the given data.
/// Use a per-request arena allocator for best performance.
pub fn render(self: *const CompiledTemplate, allocator: std.mem.Allocator, data: anytype) ![]u8 {
// Create context with data
var ctx = Context.init(allocator);
defer ctx.deinit();
// Populate context from data struct
try ctx.pushScope();
inline for (std.meta.fields(@TypeOf(data))) |field| {
const value = @field(data, field.name);
try ctx.set(field.name, runtime.toValue(allocator, value));
}
// Create runtime
var rt = Runtime.init(allocator, &ctx, .{
.pretty = self.options.pretty,
.base_dir = self.options.base_dir,
.mixins_dir = self.options.mixins_dir,
.file_resolver = null,
});
defer rt.deinit();
return rt.renderOwned(self.doc);
}
/// Renders with a pre-converted Value context (avoids toValue overhead).
pub fn renderWithValue(self: *const CompiledTemplate, allocator: std.mem.Allocator, data: runtime.Value) ![]u8 {
var ctx = Context.init(allocator);
defer ctx.deinit();
// Populate context from Value object
try ctx.pushScope();
switch (data) {
.object => |obj| {
var iter = obj.iterator();
while (iter.next()) |entry| {
try ctx.set(entry.key_ptr.*, entry.value_ptr.*);
}
},
else => {},
}
var rt = Runtime.init(allocator, &ctx, .{
.pretty = self.options.pretty,
.base_dir = self.options.base_dir,
.mixins_dir = self.options.mixins_dir,
.file_resolver = null,
});
defer rt.deinit();
return rt.renderOwned(self.doc);
}
};
// ─────────────────────────────────────────────────────────────────────────────
// Tests
// ─────────────────────────────────────────────────────────────────────────────
@@ -180,3 +339,41 @@ test "ViewEngine resolves paths correctly" {
// This test requires a views directory - skip in unit tests
// Full integration tests are in src/tests/
}
test "CompiledTemplate basic usage" {
const allocator = std.testing.allocator;
var tpl = try CompiledTemplate.init(allocator, "h1 Hello, #{name}!");
defer tpl.deinit();
// Render multiple times
for (0..3) |_| {
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
const html = try tpl.render(arena.allocator(), .{ .name = "World" });
try std.testing.expectEqualStrings("<h1>Hello, World!</h1>\n", html);
}
}
test "CompiledTemplate with loop" {
const allocator = std.testing.allocator;
var tpl = try CompiledTemplate.init(allocator,
\\ul
\\ each item in items
\\ li= item
);
defer tpl.deinit();
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
const html = try tpl.render(arena.allocator(), .{
.items = &[_][]const u8{ "a", "b", "c" },
});
try std.testing.expect(std.mem.indexOf(u8, html, "<li>a</li>") != null);
try std.testing.expect(std.mem.indexOf(u8, html, "<li>b</li>") != null);
try std.testing.expect(std.mem.indexOf(u8, html, "<li>c</li>") != null);
}