diff --git a/.gitignore b/.gitignore index 49f3962..4d574c5 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ zig-cache/ .pugz-cache/ node_modules +# compiled template file +generated.zig + # IDE .vscode/ .idea/ diff --git a/CLAUDE.md b/CLAUDE.md index 10f805a..63a9a76 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,16 +10,24 @@ Pugz is a Pug-like HTML template engine written in Zig 0.15.2. It implements Pug - `zig build` - Build the project (output in `zig-out/`) - `zig build test` - Run all tests -- `zig build app-01` - Run the example web app (http://localhost:8080) +- `zig build bench-compiled` - Run compiled templates benchmark (compare with Pug.js) ## Architecture Overview -The template engine follows a classic compiler pipeline: +The template engine supports two rendering modes: +### 1. Runtime Rendering (Interpreted) ``` Source → Lexer → Tokens → Parser → AST → Runtime → HTML ``` +### 2. Build-Time Compilation (Compiled) +``` +Source → Lexer → Tokens → Parser → AST → build_templates.zig → generated.zig → Native Zig Code +``` + +The compiled mode is **~3x faster** than Pug.js. + ### Core Modules | Module | Purpose | @@ -28,13 +36,93 @@ Source → Lexer → Tokens → Parser → AST → Runtime → HTML | **src/parser.zig** | Converts token stream into AST. Handles nesting via indent/dedent tokens. | | **src/ast.zig** | AST node definitions (Element, Text, Conditional, Each, Mixin, etc.) | | **src/runtime.zig** | Evaluates AST with data context, produces final HTML. Handles variable interpolation, conditionals, loops, mixins. | -| **src/codegen.zig** | Static HTML generation (without runtime evaluation). Outputs placeholders for dynamic content. | +| **src/build_templates.zig** | Build-time template compiler. Generates optimized Zig code from `.pug` templates. | | **src/view_engine.zig** | High-level ViewEngine for web servers. Manages views directory, auto-loads mixins. | -| **src/root.zig** | Public library API - exports `ViewEngine`, `renderTemplate()` and core types. | +| **src/root.zig** | Public library API - exports `ViewEngine`, `renderTemplate()`, `build_templates` and core types. | ### Test Files - **src/tests/general_test.zig** - Comprehensive integration tests for all features +- **src/tests/doctype_test.zig** - Doctype-specific tests +- **src/tests/inheritance_test.zig** - Template inheritance tests + +## Build-Time Template Compilation + +For maximum performance, templates can be compiled to native Zig code at build time. + +### Setup in build.zig + +```zig +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const pugz_dep = b.dependency("pugz", .{}); + + // Compile templates at build time + const build_templates = @import("pugz").build_templates; + const compiled_templates = build_templates.compileTemplates(b, .{ + .source_dir = "views", // Directory containing .pug files + }); + + const exe = b.addExecutable(.{ + .name = "myapp", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .imports = &.{ + .{ .name = "pugz", .module = pugz_dep.module("pugz") }, + .{ .name = "tpls", .module = compiled_templates }, + }, + }), + }); +} +``` + +### Usage in Code + +```zig +const tpls = @import("tpls"); + +pub fn handleRequest(allocator: std.mem.Allocator) ![]u8 { + // Zero-cost template rendering - just native Zig code + return try tpls.home(allocator, .{ + .title = "Welcome", + .user = .{ .name = "Alice", .email = "alice@example.com" }, + .items = &[_][]const u8{ "One", "Two", "Three" }, + }); +} +``` + +### Generated Code Features + +The compiler generates optimized Zig code with: +- **Static string merging** - Consecutive static content merged into single `appendSlice` calls +- **Zero allocation for static templates** - Returns string literal directly +- **Type-safe data access** - Uses `@field(d, "name")` for compile-time checked field access +- **Automatic type conversion** - `strVal()` helper converts integers to strings +- **Optional handling** - Nullable slices handled with `orelse &.{}` +- **HTML escaping** - Lookup table for fast character escaping + +### Benchmark Results (2000 iterations) + +| Template | Pug.js | Pugz | Speedup | +|----------|--------|------|---------| +| simple-0 | 0.8ms | 0.1ms | **8x** | +| simple-1 | 1.4ms | 0.6ms | **2.3x** | +| simple-2 | 1.8ms | 0.6ms | **3x** | +| if-expression | 0.6ms | 0.2ms | **3x** | +| projects-escaped | 4.4ms | 0.6ms | **7.3x** | +| search-results | 15.2ms | 5.6ms | **2.7x** | +| friends | 153.5ms | 54.0ms | **2.8x** | +| **TOTAL** | **177.6ms** | **61.6ms** | **~3x faster** | + +Run benchmarks: +```bash +# Pugz (Zig) +zig build bench-compiled + +# Pug.js (for comparison) +cd src/benchmarks/pugjs && npm install && npm run bench +``` ## Memory Management @@ -57,6 +145,8 @@ The lexer tracks several states for handling complex syntax: - `in_raw_block` / `raw_block_indent` / `raw_block_started` - For dot block text (e.g., `script.`) - `indent_stack` - Stack-based indent/dedent token generation +**Important**: The lexer distinguishes between `#id` (ID selector), `#{expr}` (interpolation), and `#[tag]` (tag interpolation) by looking ahead at the next character. + ### Token Types Key tokens: `tag`, `class`, `id`, `lparen`, `rparen`, `attr_name`, `attr_value`, `text`, `interp_start`, `interp_end`, `indent`, `dedent`, `dot_block`, `pipe_text`, `literal_html`, `self_close`, `mixin_call`, etc. @@ -125,6 +215,9 @@ p. Multi-line text block
Literal HTML
// passed through as-is + +// Interpolation-only text works too +h1.header #{title} // rendersLorem ipsum dolor sit amet, consectetur adipiscing elit.
", - .projects = &[_]Project{ - .{ .name = "Facebook", .url = "http://facebook.com", .description = "Social network" }, - .{ .name = "Google", .url = "http://google.com", .description = "Search engine" }, - .{ .name = "Twitter", .url = "http://twitter.com", .description = "Microblogging service" }, - .{ .name = "Amazon", .url = "http://amazon.com", .description = "Online retailer" }, - .{ .name = "eBay", .url = "http://ebay.com", .description = "Online auction" }, - .{ .name = "Wikipedia", .url = "http://wikipedia.org", .description = "A free encyclopedia" }, - .{ .name = "LiveJournal", .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, - }); -} diff --git a/src/benchmarks/profile_friends.zig b/src/benchmarks/profile_friends.zig deleted file mode 100644 index 43c50e0..0000000 --- a/src/benchmarks/profile_friends.zig +++ /dev/null @@ -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", .{}); -} diff --git a/src/build_templates.zig b/src/build_templates.zig new file mode 100644 index 0000000..870f185 --- /dev/null +++ b/src/build_templates.zig @@ -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['&'] = "&"; + \\ t['<'] = "<"; + \\ t['>'] = ">"; + \\ t['"'] = """; + \\ t['\''] = "'"; + \\ 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(""); + } else { + 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(""); + }, + .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); +} diff --git a/src/compiler.zig b/src/compiler.zig new file mode 100644 index 0000000..c4ef9f4 --- /dev/null +++ b/src/compiler.zig @@ -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['&'] = "&"; + \\ t['<'] = "<"; + \\ t['>'] = ">"; + \\ t['"'] = """; + \\ t['\''] = "'"; + \\ 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(\"\");\n"); + } else { + try self.w("try out.appendSlice(\"\");\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(\"\");\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}); +} diff --git a/src/examples/demo/main.zig b/src/examples/demo/main.zig deleted file mode 100644 index ec97ca7..0000000 --- a/src/examples/demo/main.zig +++ /dev/null @@ -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; -} diff --git a/src/lexer.zig b/src/lexer.zig index 71cecc4..7ae78c6 100644 --- a/src/lexer.zig +++ b/src/lexer.zig @@ -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; diff --git a/src/root.zig b/src/root.zig index 9b89326..5ccd8b7 100644 --- a/src/root.zig +++ b/src/root.zig @@ -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()); diff --git a/src/runtime.zig b/src/runtime.zig index ccb01e7..841530a 100644 --- a/src/runtime.zig +++ b/src/runtime.zig @@ -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) { - '&' => "&", - '<' => "<", - '>' => ">", - '"' => """, - '\'' => "'", - 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['&'] = "&"; + strings['<'] = "<"; + strings['>'] = ">"; + strings['"'] = """; + strings['\''] = "'"; + 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 }; }, diff --git a/src/tests/general_test.zig b/src/tests/general_test.zig index 5da79db..3694c01 100644 --- a/src/tests/general_test.zig +++ b/src/tests/general_test.zig @@ -14,6 +14,27 @@ test "Simple interpolation" { ); } +test "Interpolation only as text" { + try expectOutput( + "h1.header #{header}", + .{ .header = "MyHeader" }, + "