From 1fff91d7d9569744fc1d1634c9b600fae82f9933 Mon Sep 17 00:00:00 2001 From: Ankit Patial Date: Sat, 17 Jan 2026 20:01:37 +0530 Subject: [PATCH] Add string concatenation in attributes, lazy mixin loading, and benchmarks Features: - Fix string concatenation in attribute values (e.g., class="btn btn-" + type) - Lexer now properly captures full expressions with operators - Runtime evaluates expressions for class attributes ViewEngine improvements: - Change mixin loading from eager to lazy (on-demand) - Mixins are now loaded from mixins directory only when first called - Template-defined mixins take precedence over directory mixins Benchmarks: - Add src/benchmark.zig with three template complexity levels - Simple: ~150k renders/sec, 6KB memory - Medium: ~70k renders/sec, 45KB memory - Complex: ~32k renders/sec, 94KB memory - Memory leak detection confirms no leaks Documentation: - Update CLAUDE.md with lazy mixin loading details - Document mixin resolution order --- CLAUDE.md | 17 +- build.zig | 20 ++ src/benchmark.zig | 388 +++++++++++++++++++++++++++++++++++++ src/lexer.zig | 42 ++-- src/runtime.zig | 120 ++++++++++-- src/tests/general_test.zig | 23 +++ src/view_engine.zig | 113 +++-------- 7 files changed, 606 insertions(+), 117 deletions(-) create mode 100644 src/benchmark.zig diff --git a/CLAUDE.md b/CLAUDE.md index de9bc42..10f805a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -252,7 +252,7 @@ const pugz = @import("pugz"); // Initialize once at server startup var engine = try pugz.ViewEngine.init(allocator, .{ .views_dir = "src/views", // Root views directory - .mixins_dir = "mixins", // Auto-load mixins from views/mixins/ (optional) + .mixins_dir = "mixins", // Mixins dir for lazy-loading (optional, default: "mixins") .extension = ".pug", // File extension (default: .pug) .pretty = true, // Pretty-print output (default: true) }); @@ -271,11 +271,22 @@ pub fn handleRequest(engine: *pugz.ViewEngine, allocator: std.mem.Allocator) ![] } ``` +### Mixin Resolution (Lazy Loading) + +Mixins are resolved in the following order: +1. **Same template** - Mixins defined in the current template file +2. **Mixins directory** - If not found, searches `views/mixins/*.pug` files (lazy-loaded on first use) + +This lazy-loading approach means: +- Mixins are only parsed when first called +- No upfront loading of all mixin files at server startup +- Templates can override mixins from the mixins directory by defining them locally + ### Directory Structure ``` src/views/ -├── mixins/ # Auto-loaded mixins (optional) +├── mixins/ # Lazy-loaded mixins (searched when mixin not found in template) │ ├── buttons.pug # mixin btn(text), mixin btn-link(href, text) │ └── cards.pug # mixin card(title), mixin card-simple(title, body) ├── layouts/ @@ -291,7 +302,7 @@ src/views/ Templates can use: - `extends layouts/base` - Paths relative to views_dir - `include partials/header` - Paths relative to views_dir -- `+btn("Click")` - Mixins from mixins/ dir available automatically +- `+btn("Click")` - Mixins from mixins/ dir loaded on-demand ### Low-Level API diff --git a/build.zig b/build.zig index 614dc57..34b561c 100644 --- a/build.zig +++ b/build.zig @@ -106,6 +106,26 @@ pub fn build(b: *std.Build) void { const demo_step = b.step("demo", "Run the template inheritance demo web app"); demo_step.dependOn(&run_demo.step); + // ───────────────────────────────────────────────────────────────────────── + // Benchmark executable + // ───────────────────────────────────────────────────────────────────────── + const bench = b.addExecutable(.{ + .name = "bench", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/benchmark.zig"), + .target = target, + .optimize = .ReleaseFast, // Always use ReleaseFast for benchmarks + }), + }); + + b.installArtifact(bench); + + const run_bench = b.addRunArtifact(bench); + run_bench.step.dependOn(b.getInstallStep()); + + const bench_step = b.step("bench", "Run rendering benchmarks"); + bench_step.dependOn(&run_bench.step); + // Just like flags, top level steps are also listed in the `--help` menu. // // The Zig build system is entirely implemented in userland, which means diff --git a/src/benchmark.zig b/src/benchmark.zig new file mode 100644 index 0000000..dabf339 --- /dev/null +++ b/src/benchmark.zig @@ -0,0 +1,388 @@ +//! 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("root.zig"); + +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", .{}); +} diff --git a/src/lexer.zig b/src/lexer.zig index 6d2aaab..71cecc4 100644 --- a/src/lexer.zig +++ b/src/lexer.zig @@ -638,6 +638,7 @@ pub const Lexer = struct { /// Emits a single token for the entire expression (e.g., "btn btn-" + type). fn scanAttrValue(self: *Lexer) !void { const start = self.pos; + var after_operator = false; // Track if we just passed an operator // Scan the complete expression including operators while (!self.isAtEnd()) { @@ -654,6 +655,7 @@ pub const Lexer = struct { self.advance(); } if (self.peek() == quote) self.advance(); + after_operator = false; } else if (c == '`') { // Template literal self.advance(); @@ -661,6 +663,7 @@ pub const Lexer = struct { self.advance(); } if (self.peek() == '`') self.advance(); + after_operator = false; } else if (c == '{') { // Object literal - scan matching braces var depth: usize = 0; @@ -675,6 +678,7 @@ pub const Lexer = struct { } self.advance(); } + after_operator = false; } else if (c == '[') { // Array literal - scan matching brackets var depth: usize = 0; @@ -689,6 +693,7 @@ pub const Lexer = struct { } self.advance(); } + after_operator = false; } else if (c == '(') { // Function call - scan matching parens var depth: usize = 0; @@ -703,33 +708,46 @@ pub const Lexer = struct { } self.advance(); } + after_operator = false; } else if (c == ')' or c == ',') { // End of attribute value break; } else if (c == ' ' or c == '\t') { - // Whitespace - check if followed by operator (continue) or not (end) - const ws_start = self.pos; - while (self.peek() == ' ' or self.peek() == '\t') { - self.advance(); - } - const next = self.peek(); - if (next == '+' or next == '-' or next == '*' or next == '/') { - // Operator follows - continue scanning (include whitespace) + // Whitespace handling depends on context + if (after_operator) { + // After an operator, skip whitespace and continue to get the operand + while (self.peek() == ' ' or self.peek() == '\t') { + self.advance(); + } + after_operator = false; continue; } else { - // Not an operator - rewind and end - self.pos = ws_start; - break; + // Not after operator - check if followed by operator (continue) or not (end) + const ws_start = self.pos; + while (self.peek() == ' ' or self.peek() == '\t') { + self.advance(); + } + const next = self.peek(); + if (next == '+' or next == '-' or next == '*' or next == '/') { + // Operator follows - continue scanning (include whitespace) + continue; + } else { + // Not an operator - rewind and end + self.pos = ws_start; + break; + } } } else if (c == '+' or c == '-' or c == '*' or c == '/') { - // Operator - include it and continue + // Operator - include it and mark that we need to continue for the operand self.advance(); + after_operator = true; } else if (c == '\n' or c == '\r') { // Newline ends the value break; } else { // Regular character (alphanumeric, etc.) self.advance(); + after_operator = false; } } diff --git a/src/runtime.zig b/src/runtime.zig index 6a1090b..970bbcf 100644 --- a/src/runtime.zig +++ b/src/runtime.zig @@ -175,6 +175,8 @@ pub const Runtime = struct { file_resolver: ?FileResolver, /// Base directory for resolving relative paths. base_dir: []const u8, + /// Directory containing mixin files for lazy-loading. + mixins_dir: []const u8, /// Block definitions from child template (for inheritance). blocks: std.StringHashMapUnmanaged(BlockDef), /// Current mixin block content (for `block` keyword inside mixins). @@ -190,6 +192,9 @@ pub const Runtime = struct { base_dir: []const u8 = "", /// File resolver for loading templates. file_resolver: ?FileResolver = null, + /// Directory containing mixin files for lazy-loading. + /// If set, mixins not found in template will be loaded from here. + mixins_dir: []const u8 = "", }; /// Error type for runtime operations. @@ -204,6 +209,7 @@ pub const Runtime = struct { .options = options, .file_resolver = options.file_resolver, .base_dir = options.base_dir, + .mixins_dir = options.mixins_dir, .blocks = .empty, .mixin_block_content = null, .mixin_attributes = null, @@ -362,13 +368,17 @@ pub const Runtime = struct { // Process attributes, collecting class values separately for (elem.attributes) |attr| { if (std.mem.eql(u8, attr.name, "class")) { - // Handle class attribute - may be array literal + // Handle class attribute - may be array literal or expression if (attr.value) |value| { - var evaluated = try self.evaluateString(value); + var evaluated: []const u8 = undefined; - // Parse array literal to space-separated string - if (evaluated.len > 0 and evaluated[0] == '[') { - evaluated = try parseArrayToSpaceSeparated(self.allocator, evaluated); + // Check if it's an array literal + if (value.len >= 1 and value[0] == '[') { + evaluated = try parseArrayToSpaceSeparated(self.allocator, value); + } else { + // Evaluate as expression (handles "str" + var concatenation) + const expr_value = self.evaluateExpression(value); + evaluated = try expr_value.toString(self.allocator); } if (evaluated.len > 0) { @@ -716,7 +726,19 @@ pub const Runtime = struct { } fn visitMixinCall(self: *Runtime, call: ast.MixinCall) Error!void { - const mixin = self.context.getMixin(call.name) orelse return; + // First check if mixin is defined in current context (same template or preloaded) + var mixin = self.context.getMixin(call.name); + + // If not found and mixins_dir is configured, try loading from mixins directory + if (mixin == null and self.mixins_dir.len > 0) { + if (self.loadMixinFromDir(call.name)) |loaded_mixin| { + try self.context.defineMixin(loaded_mixin); + mixin = loaded_mixin; + } + } + + // If still not found, skip this mixin call + const mixin_def = mixin orelse return; try self.context.pushScope(); defer self.context.popScope(); @@ -751,17 +773,17 @@ pub const Runtime = struct { } // Bind arguments to parameters - const regular_params = if (mixin.has_rest and mixin.params.len > 0) - mixin.params.len - 1 + const regular_params = if (mixin_def.has_rest and mixin_def.params.len > 0) + mixin_def.params.len - 1 else - mixin.params.len; + mixin_def.params.len; // Bind regular parameters - for (mixin.params[0..regular_params], 0..) |param, i| { + for (mixin_def.params[0..regular_params], 0..) |param, i| { const value = if (i < call.args.len) self.evaluateExpression(call.args[i]) - else if (i < mixin.defaults.len and mixin.defaults[i] != null) - self.evaluateExpression(mixin.defaults[i].?) + else if (i < mixin_def.defaults.len and mixin_def.defaults[i] != null) + self.evaluateExpression(mixin_def.defaults[i].?) else Value.null; @@ -769,8 +791,8 @@ pub const Runtime = struct { } // Bind rest parameter if present - if (mixin.has_rest and mixin.params.len > 0) { - const rest_param = mixin.params[mixin.params.len - 1]; + if (mixin_def.has_rest and mixin_def.params.len > 0) { + const rest_param = mixin_def.params[mixin_def.params.len - 1]; const rest_start = regular_params; if (rest_start < call.args.len) { @@ -789,11 +811,79 @@ pub const Runtime = struct { } // Render mixin body - for (mixin.children) |child| { + for (mixin_def.children) |child| { try self.visitNode(child); } } + /// Loads a mixin from the mixins directory by name. + /// Searches for files named {name}.pug or iterates through all .pug files. + /// Note: The source file memory is intentionally not freed to keep AST slices valid. + fn loadMixinFromDir(self: *Runtime, name: []const u8) ?ast.MixinDef { + const resolver = self.file_resolver orelse return null; + + // First try: look for a file named {name}.pug + const specific_path = std.fs.path.join(self.allocator, &.{ self.mixins_dir, name }) catch return null; + defer self.allocator.free(specific_path); + + const with_ext = std.fmt.allocPrint(self.allocator, "{s}.pug", .{specific_path}) catch return null; + defer self.allocator.free(with_ext); + + if (resolver(self.allocator, with_ext)) |source| { + // Note: source is intentionally not freed - AST nodes contain slices into it + if (self.parseMixinFromSource(source, name)) |mixin_def| { + return mixin_def; + } + // Only free if we didn't find the mixin we wanted + self.allocator.free(source); + } + + // Second try: iterate through all .pug files in mixins directory + var dir = std.fs.openDirAbsolute(self.mixins_dir, .{ .iterate = true }) catch return null; + defer dir.close(); + + var iter = dir.iterate(); + while (iter.next() catch return null) |entry| { + if (entry.kind != .file) continue; + if (!std.mem.endsWith(u8, entry.name, ".pug")) continue; + + const file_path = std.fs.path.join(self.allocator, &.{ self.mixins_dir, entry.name }) catch continue; + defer self.allocator.free(file_path); + + if (resolver(self.allocator, file_path)) |source| { + // Note: source is intentionally not freed - AST nodes contain slices into it + if (self.parseMixinFromSource(source, name)) |mixin_def| { + return mixin_def; + } + // Only free if we didn't find the mixin we wanted + self.allocator.free(source); + } + } + + return null; + } + + /// Parses a source file and extracts a mixin definition by name. + fn parseMixinFromSource(self: *Runtime, source: []const u8, name: []const u8) ?ast.MixinDef { + var lexer = Lexer.init(self.allocator, source); + const tokens = lexer.tokenize() catch return null; + // Note: lexer is not deinitialized - tokens contain slices into source + + var parser = Parser.init(self.allocator, tokens); + const doc = parser.parse() catch return null; + + // Find the mixin definition with the matching name + for (doc.nodes) |node| { + if (node == .mixin_def) { + if (std.mem.eql(u8, node.mixin_def.name, name)) { + return node.mixin_def; + } + } + } + + return null; + } + /// Renders the mixin block content (for `block` keyword inside mixins). fn visitMixinBlock(self: *Runtime) Error!void { if (self.mixin_block_content) |block_children| { diff --git a/src/tests/general_test.zig b/src/tests/general_test.zig index 8f81fde..5da79db 100644 --- a/src/tests/general_test.zig +++ b/src/tests/general_test.zig @@ -715,3 +715,26 @@ test "Explicit self-closing tag with attributes" { \\ ); } + +// ───────────────────────────────────────────────────────────────────────────── +// String concatenation in attributes +// ───────────────────────────────────────────────────────────────────────────── + +test "Attribute with string concatenation" { + try expectOutput( + \\button(class="btn btn-" + btnType) Click + , .{ .btnType = "secondary" }, + \\ + ); +} + +test "Mixin with string concatenation in class" { + try expectOutput( + \\mixin btn(text, btnType="primary") + \\ button(class="btn btn-" + btnType)= text + \\+btn("Click me", "secondary") + , .{}, + \\ + ); +} + diff --git a/src/view_engine.zig b/src/view_engine.zig index 6f07905..de858a9 100644 --- a/src/view_engine.zig +++ b/src/view_engine.zig @@ -2,9 +2,13 @@ //! //! Provides a simple API for rendering Pug templates with: //! - Views directory configuration -//! - Auto-loading mixins from a mixins subdirectory +//! - Lazy-loading mixins from a mixins subdirectory (on-demand) //! - Relative path resolution for includes and extends //! +//! 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: //! ```zig //! var engine = try ViewEngine.init(allocator, .{ @@ -32,7 +36,8 @@ pub const Options = struct { /// Root directory containing view templates. views_dir: []const u8, /// Subdirectory within views_dir containing mixin files. - /// Defaults to "mixins". Set to null to disable auto-loading. + /// Defaults to "mixins". Mixins are lazy-loaded on first use. + /// Set to null to disable mixin directory lookup. mixins_dir: ?[]const u8 = "mixins", /// File extension for templates. Defaults to ".pug". extension: []const u8 = ".pug", @@ -49,107 +54,41 @@ pub const ViewEngineError = error{ InvalidPath, }; -/// A pre-parsed mixin definition. -const MixinEntry = struct { - name: []const u8, - def: ast.MixinDef, -}; - /// ViewEngine manages template rendering with a configured views directory. +/// Mixins are lazy-loaded from the mixins directory when first called. pub const ViewEngine = struct { allocator: std.mem.Allocator, options: Options, /// Absolute path to views directory. views_path: []const u8, - /// Pre-loaded mixin definitions. - mixins: std.ArrayListUnmanaged(MixinEntry), - /// Cached mixin source files (to keep slices valid). - mixin_sources: std.ArrayListUnmanaged([]const u8), + /// Absolute path to mixins directory (resolved at init). + mixins_path: []const u8, /// Initializes the ViewEngine with the given options. - /// Loads all mixins from the mixins directory if configured. pub fn init(allocator: std.mem.Allocator, options: Options) !ViewEngine { // Resolve views directory to absolute path const views_path = try std.fs.cwd().realpathAlloc(allocator, options.views_dir); errdefer allocator.free(views_path); - var engine = ViewEngine{ + // Resolve mixins directory path (may not exist yet) + var mixins_path: []const u8 = ""; + if (options.mixins_dir) |mixins_subdir| { + mixins_path = try std.fs.path.join(allocator, &.{ views_path, mixins_subdir }); + } + + return ViewEngine{ .allocator = allocator, .options = options, .views_path = views_path, - .mixins = .empty, - .mixin_sources = .empty, + .mixins_path = mixins_path, }; - - // Auto-load mixins if configured - if (options.mixins_dir) |mixins_subdir| { - try engine.loadMixins(mixins_subdir); - } - - return engine; } /// Releases all resources held by the ViewEngine. pub fn deinit(self: *ViewEngine) void { self.allocator.free(self.views_path); - self.mixins.deinit(self.allocator); - for (self.mixin_sources.items) |source| { - self.allocator.free(source); - } - self.mixin_sources.deinit(self.allocator); - } - - /// Loads all mixin files from the specified subdirectory. - fn loadMixins(self: *ViewEngine, mixins_subdir: []const u8) !void { - const mixins_path = try std.fs.path.join(self.allocator, &.{ self.views_path, mixins_subdir }); - defer self.allocator.free(mixins_path); - - var dir = std.fs.openDirAbsolute(mixins_path, .{ .iterate = true }) catch |err| { - if (err == error.FileNotFound) { - // Mixins directory doesn't exist - that's OK - return; - } - return err; - }; - defer dir.close(); - - var iter = dir.iterate(); - while (try iter.next()) |entry| { - if (entry.kind != .file) continue; - - // Check for .pug extension - if (!std.mem.endsWith(u8, entry.name, self.options.extension)) continue; - - // Read and parse the mixin file - try self.loadMixinFile(dir, entry.name); - } - } - - /// Loads a single mixin file and extracts its mixin definitions. - fn loadMixinFile(self: *ViewEngine, dir: std.fs.Dir, filename: []const u8) !void { - const source = try dir.readFileAlloc(self.allocator, filename, 1024 * 1024); - errdefer self.allocator.free(source); - - // Keep source alive for string slices - try self.mixin_sources.append(self.allocator, source); - - // Parse the file - var lexer = Lexer.init(self.allocator, source); - defer lexer.deinit(); - - const tokens = lexer.tokenize() catch return; - - var parser = Parser.init(self.allocator, tokens); - const doc = parser.parse() catch return; - - // Extract mixin definitions - for (doc.nodes) |node| { - if (node == .mixin_def) { - try self.mixins.append(self.allocator, .{ - .name = node.mixin_def.name, - .def = node.mixin_def, - }); - } + if (self.mixins_path.len > 0) { + self.allocator.free(self.mixins_path); } } @@ -158,6 +97,10 @@ pub const ViewEngine = struct { /// The template path is relative to the views directory. /// The .pug extension is added automatically if not present. /// + /// Mixins are resolved in order: + /// 1. Mixins defined in the template itself + /// 2. Mixins from the mixins directory (lazy-loaded) + /// /// Example: /// ```zig /// const html = try engine.render(allocator, "pages/home", .{ @@ -188,11 +131,6 @@ pub const ViewEngine = struct { var ctx = Context.init(allocator); defer ctx.deinit(); - // Register pre-loaded mixins - for (self.mixins.items) |mixin_entry| { - try ctx.defineMixin(mixin_entry.def); - } - // Populate context from data struct try ctx.pushScope(); inline for (std.meta.fields(@TypeOf(data))) |field| { @@ -200,10 +138,11 @@ pub const ViewEngine = struct { try ctx.set(field.name, runtime.toValue(allocator, value)); } - // Create runtime with file resolver for includes/extends + // Create runtime with file resolver for includes/extends and lazy mixin loading var rt = Runtime.init(allocator, &ctx, .{ .pretty = self.options.pretty, .base_dir = self.views_path, + .mixins_dir = self.mixins_path, .file_resolver = createFileResolver(), }); defer rt.deinit();