From 14128aeeea54f04fdfd3334817cdde64f3701a7d Mon Sep 17 00:00:00 2001 From: Ankit Patial Date: Wed, 28 Jan 2026 22:31:24 +0530 Subject: [PATCH] feat: add @TypeOf type hints for compiled templates - Add TypeHint node type in parser for //- @TypeOf(field): type syntax - Support scalar types (f32, i32, bool, etc.) and array/struct types - Use helpers.appendValue() for non-string typed fields - Filter out loop variable references from Data struct fields - Preserve @TypeOf comments during comment stripping Example usage: //- @TypeOf(subtotal): f32 span $#{subtotal} //- @TypeOf(items): []{name: []const u8, price: f32} each item in items h3 #{item.name} --- build.zig.zon | 2 +- examples/demo/src/main.zig | 20 +- examples/demo/views/pages/cart.pug | 14 +- src/codegen.zig | 2 +- src/parser.zig | 75 +++++ src/strip_comments.zig | 16 +- src/tpl_compiler/helpers_template.zig | 39 +++ src/tpl_compiler/zig_codegen.zig | 429 ++++++++++++++++++++++++-- src/walk.zig | 1 + 9 files changed, 562 insertions(+), 36 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 6ddf4ef..4fdc8c6 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = .pugz, - .version = "0.3.1", + .version = "0.3.2", .fingerprint = 0x822db0790e17621d, // Changing this has security and trust implications. .minimum_zig_version = "0.15.2", .dependencies = .{}, diff --git a/examples/demo/src/main.zig b/examples/demo/src/main.zig index cb989b4..8fd50b4 100644 --- a/examples/demo/src/main.zig +++ b/examples/demo/src/main.zig @@ -55,7 +55,7 @@ const CartItem = struct { const Cart = struct { items: []const CartItem, - subtotal: []const u8, + subtotal: f32, shipping: []const u8, discount: ?[]const u8 = null, discountCode: ?[]const u8 = null, @@ -189,7 +189,7 @@ const sample_cart_items = [_]CartItem{ const sample_cart = Cart{ .items = &sample_cart_items, - .subtotal = "209.98", + .subtotal = 209.98, .shipping = "0", .tax = "18.90", .total = "228.88", @@ -309,11 +309,17 @@ fn productDetail(app: *App, req: *httpz.Request, res: *httpz.Response) !void { fn cart(app: *App, _: *httpz.Request, res: *httpz.Response) !void { const html = if (USE_COMPILED_TEMPLATES) blk: { const templates = @import("templates"); - break :blk try templates.pages_cart.render(res.arena, .{ - .cartCount = "2", - .subtotal = sample_cart.subtotal, - .tax = sample_cart.tax, - .total = sample_cart.total, + const Data = templates.pages_cart.Data; + break :blk try templates.pages_cart.render(res.arena, Data{ + .cartCount = "3", + .cartItems = &.{ + .{ .variant = "Black", .name = "Wireless Headphones", .price = 79.99, .quantity = 1, .total = 79.99 }, + .{ .variant = "Silver", .name = "Laptop", .price = 500.00, .quantity = 1, .total = 500.00 }, + .{ .variant = "RGB", .name = "Mechanical Keyboard", .price = 129.99, .quantity = 1, .total = 129.99 }, + }, + .subtotal = 709.98, + .tax = 63.90, + .total = 773.88, }); } else app.view.render(res.arena, "pages/cart", .{ .title = "Shopping Cart", diff --git a/examples/demo/views/pages/cart.pug b/examples/demo/views/pages/cart.pug index df015e3..64b7f09 100644 --- a/examples/demo/views/pages/cart.pug +++ b/examples/demo/views/pages/cart.pug @@ -14,17 +14,18 @@ block content .cart-layout .cart-main .cart-items + //- @TypeOf(cartItems): []{name: []const u8, variant: []const u8, price: f32, quantity: u16, total: f32} each item in cartItems .cart-item .cart-item-info - h3 #{name} - p.text-muted #{variant} - span.cart-item-price $#{price} + h3 #{item.name} + p.text-muted #{item.variant} + span.cart-item-price $#{item.price} .cart-item-qty button.qty-btn - - input.qty-input(type="text" value=quantity) + input.qty-input(type="text" value=item.quantity) button.qty-btn + - .cart-item-total $#{total} + .cart-item-total $#{item.total} button.cart-item-remove x .cart-actions @@ -34,14 +35,17 @@ block content h3 Order Summary .summary-row span Subtotal + //- @TypeOf(subtotal): f32 span $#{subtotal} .summary-row span Shipping span.text-success Free .summary-row span Tax + //- @TypeOf(tax): f32 span $#{tax} .summary-row.summary-total span Total + //- @TypeOf(total): f32 span $#{total} a.btn.btn-primary.btn-block(href="/checkout") Proceed to Checkout diff --git a/src/codegen.zig b/src/codegen.zig index 5c06623..194f277 100644 --- a/src/codegen.zig +++ b/src/codegen.zig @@ -269,7 +269,7 @@ pub const Compiler = struct { .While => try self.visitWhile(node), .Each => try self.visitEach(node), .EachOf => try self.visitEachOf(node), - .YieldBlock => {}, // No-op + .YieldBlock, .TypeHint => {}, // No-op (TypeHint is only for compiled templates) .Include, .Extends, .RawInclude, .Filter, .IncludeFilter, .FileReference, .AttributeBlock => { // These should be processed by linker/loader before codegen return error.UnsupportedNodeType; diff --git a/src/parser.zig b/src/parser.zig index dd97ae8..0a29f0e 100644 --- a/src/parser.zig +++ b/src/parser.zig @@ -71,6 +71,7 @@ pub const NodeType = enum { FileReference, YieldBlock, AttributeBlock, + TypeHint, // Type annotation for compiled templates: //- @TypeOf(field): type }; // ============================================================================ @@ -157,6 +158,10 @@ pub const Node = struct { // When/Conditional debug field debug: bool = true, + // TypeHint fields (for //- @TypeOf(field): type annotations) + type_hint_field: ?[]const u8 = null, // Field name (e.g., "cartItems") + type_hint_type: ?[]const u8 = null, // Type spec (e.g., "[]{name: []const u8}") + // Memory ownership flags val_owned: bool = false, // true if val was allocated and needs to be freed @@ -966,6 +971,16 @@ pub const Parser = struct { fn parseComment(self: *Parser) !*Node { const tok = try self.expect(.comment); + // Check for type hint in unbuffered comment: //- @TypeOf(field): type + if (!tok.isBuffered()) { + if (tok.val.getString()) |text| { + const trimmed = mem.trim(u8, text, " \t"); + if (mem.startsWith(u8, trimmed, "@TypeOf(")) { + return self.parseTypeHint(tok, trimmed); + } + } + } + if (self.parseTextBlock()) |block| { const node = try self.allocator.create(Node); node.* = .{ @@ -997,6 +1012,66 @@ pub const Parser = struct { } } + // ======================================================================== + // TypeHint Parsing (for compiled templates) + // ======================================================================== + + /// Parse a type hint annotation: @TypeOf(fieldName): typeSpec + fn parseTypeHint(self: *Parser, tok: Token, text: []const u8) !*Node { + // Find closing paren: @TypeOf(fieldName) + const paren_start = "@TypeOf(".len; + var paren_end: usize = paren_start; + while (paren_end < text.len and text[paren_end] != ')') : (paren_end += 1) {} + + if (paren_end >= text.len) { + // Malformed type hint - treat as regular comment + const node = try self.allocator.create(Node); + node.* = .{ + .type = .Comment, + .val = tok.val.getString(), + .buffer = false, + .line = tok.loc.start.line, + .column = tok.loc.start.column, + .filename = self.filename, + }; + return node; + } + + const field_name = text[paren_start..paren_end]; + + // Find colon separator + var colon_pos = paren_end + 1; + while (colon_pos < text.len and (text[colon_pos] == ' ' or text[colon_pos] == '\t')) : (colon_pos += 1) {} + + if (colon_pos >= text.len or text[colon_pos] != ':') { + // No colon found - treat as regular comment + const node = try self.allocator.create(Node); + node.* = .{ + .type = .Comment, + .val = tok.val.getString(), + .buffer = false, + .line = tok.loc.start.line, + .column = tok.loc.start.column, + .filename = self.filename, + }; + return node; + } + + // Get type spec after colon + const type_spec = mem.trim(u8, text[colon_pos + 1 ..], " \t"); + + const node = try self.allocator.create(Node); + node.* = .{ + .type = .TypeHint, + .type_hint_field = field_name, + .type_hint_type = type_spec, + .line = tok.loc.start.line, + .column = tok.loc.start.column, + .filename = self.filename, + }; + return node; + } + // ======================================================================== // Doctype Parsing // ======================================================================== diff --git a/src/strip_comments.zig b/src/strip_comments.zig index 2254ae7..0d44e0c 100644 --- a/src/strip_comments.zig +++ b/src/strip_comments.zig @@ -24,6 +24,8 @@ pub const StripCommentsOptions = struct { strip_unbuffered: bool = true, /// Strip buffered comments (default: false) strip_buffered: bool = false, + /// Preserve unbuffered comments starting with @TypeOf (for compiled templates) + preserve_type_hints: bool = true, /// Source filename for error messages filename: ?[]const u8 = null, }; @@ -91,8 +93,20 @@ pub fn stripComments( // Check if this is a buffered comment comment_is_buffered = tok.isBuffered(); + // Check if this is a TypeHint comment that should be preserved + const is_type_hint = if (options.preserve_type_hints and !comment_is_buffered) blk2: { + if (tok.val.getString()) |text| { + const trimmed = std.mem.trim(u8, text, " \t"); + break :blk2 std.mem.startsWith(u8, trimmed, "@TypeOf("); + } + break :blk2 false; + } else false; + // Determine if we should strip this comment - if (comment_is_buffered) { + if (is_type_hint) { + // Always preserve TypeHint comments + in_comment = false; + } else if (comment_is_buffered) { in_comment = options.strip_buffered; } else { in_comment = options.strip_unbuffered; diff --git a/src/tpl_compiler/helpers_template.zig b/src/tpl_compiler/helpers_template.zig index a992b8e..b1d9973 100644 --- a/src/tpl_compiler/helpers_template.zig +++ b/src/tpl_compiler/helpers_template.zig @@ -31,3 +31,42 @@ pub fn isTruthy(val: anytype) bool { else => true, }; } + +/// Append an integer value to buffer (formatted as decimal string) +pub fn appendInt(buf: *std.ArrayListUnmanaged(u8), allocator: std.mem.Allocator, value: anytype) !void { + var tmp: [32]u8 = undefined; + const str = std.fmt.bufPrint(&tmp, "{d}", .{value}) catch return; + try buf.appendSlice(allocator, str); +} + +/// Append a float value to buffer (formatted with 2 decimal places) +pub fn appendFloat(buf: *std.ArrayListUnmanaged(u8), allocator: std.mem.Allocator, value: anytype) !void { + var tmp: [64]u8 = undefined; + const str = std.fmt.bufPrint(&tmp, "{d:.2}", .{value}) catch return; + try buf.appendSlice(allocator, str); +} + +/// Append any value to buffer (auto-detects type) +pub fn appendValue(buf: *std.ArrayListUnmanaged(u8), allocator: std.mem.Allocator, value: anytype) !void { + const T = @TypeOf(value); + switch (@typeInfo(T)) { + .int, .comptime_int => try appendInt(buf, allocator, value), + .float, .comptime_float => try appendFloat(buf, allocator, value), + .bool => try buf.appendSlice(allocator, if (value) "true" else "false"), + .pointer => |ptr| { + if (ptr.size == .slice and ptr.child == u8) { + // String slice + try appendEscaped(buf, allocator, value); + } else { + // Other slices - not directly supported + try buf.appendSlice(allocator, "[...]"); + } + }, + else => { + // Fallback - try to format it + var tmp: [128]u8 = undefined; + const str = std.fmt.bufPrint(&tmp, "{any}", .{value}) catch "[?]"; + try buf.appendSlice(allocator, str); + }, + } +} diff --git a/src/tpl_compiler/zig_codegen.zig b/src/tpl_compiler/zig_codegen.zig index 3c4d606..d2ab183 100644 --- a/src/tpl_compiler/zig_codegen.zig +++ b/src/tpl_compiler/zig_codegen.zig @@ -29,6 +29,20 @@ pub const ZigCodegenError = error{ UnsupportedFeature, }; +/// Type information parsed from @TypeOf annotations +pub const TypeInfo = struct { + is_array: bool = false, + struct_fields: ?std.StringHashMap([]const u8) = null, + primitive_type: ?[]const u8 = null, + + pub fn deinit(self: *TypeInfo, allocator: Allocator) void { + if (self.struct_fields) |*fields| { + fields.deinit(); + } + _ = allocator; + } +}; + pub const Codegen = struct { allocator: Allocator, output: std.ArrayListUnmanaged(u8), @@ -36,6 +50,10 @@ pub const Codegen = struct { terse: bool, // HTML5 mode vs XHTML // Buffer for combining consecutive static strings static_buffer: std.ArrayListUnmanaged(u8), + // Type hints from @TypeOf annotations + type_hints: std.StringHashMap(TypeInfo), + // Current loop variable for field resolution inside each blocks + current_loop_var: ?[]const u8 = null, pub fn init(allocator: Allocator) Codegen { return .{ @@ -44,12 +62,22 @@ pub const Codegen = struct { .indent_level = 0, .terse = true, // Default to HTML5 .static_buffer = .{}, + .type_hints = std.StringHashMap(TypeInfo).init(allocator), + .current_loop_var = null, }; } pub fn deinit(self: *Codegen) void { self.output.deinit(self.allocator); self.static_buffer.deinit(self.allocator); + // Clean up type hints + var iter = self.type_hints.valueIterator(); + while (iter.next()) |info| { + if (info.struct_fields) |*fields| { + fields.deinit(); + } + } + self.type_hints.deinit(); } /// Generate Zig code for a template @@ -59,6 +87,19 @@ pub const Codegen = struct { self.static_buffer.clearRetainingCapacity(); self.indent_level = 0; self.terse = true; + self.current_loop_var = null; + + // Clean up any existing type hints + var hint_iter = self.type_hints.valueIterator(); + while (hint_iter.next()) |info| { + if (info.struct_fields) |*sf| { + sf.deinit(); + } + } + self.type_hints.clearRetainingCapacity(); + + // Collect type hints from AST + try collectTypeHints(self.allocator, ast, &self.type_hints); // Detect doctype to set terse mode self.detectDoctype(ast); @@ -68,14 +109,23 @@ pub const Codegen = struct { try self.writeLine("const helpers = @import(\"helpers.zig\");"); try self.writeLine(""); - // Generate Data struct + // Generate Data struct with typed fields try self.writeIndent(); try self.writeLine("pub const Data = struct {"); self.indent_level += 1; + for (fields) |field| { try self.writeIndent(); try self.write(field); - try self.writeLine(": []const u8 = \"\","); + + // Check if we have a type hint for this field + if (self.type_hints.get(field)) |type_info| { + try self.write(": "); + try self.writeTypeInfo(type_info); + } else { + try self.write(": []const u8 = \"\""); + } + try self.writeLine(","); } self.indent_level -= 1; try self.writeIndent(); @@ -135,6 +185,8 @@ pub const Codegen = struct { .BlockComment => try self.generateBlockComment(node), .Doctype => try self.generateDoctype(node), .Conditional => try self.generateConditional(node), + .Each, .EachOf => try self.generateEach(node), + .TypeHint => {}, // Skip - processed during field extraction else => { // Unsupported nodes: skip or process children for (node.nodes.items) |child| { @@ -203,14 +255,27 @@ pub const Codegen = struct { try self.write(attr.name); try self.writeLine("=\\\"\");"); + // Check if this is a loop variable reference or typed field + const is_loop_var = self.isLoopVariableReference(val); + const needs_value_helper = is_loop_var or self.hasNonStringTypeHint(val); + try self.writeIndent(); - if (attr.must_escape) { + if (needs_value_helper) { + // Use appendValue for typed fields (handles any type) + try self.write("try helpers.appendValue(&buf, allocator, "); + if (is_loop_var) { + try self.writeFieldReference(val); + } else { + try self.write("data."); + try self.writeSanitizedFieldName(val); + } + } else if (attr.must_escape) { try self.write("try helpers.appendEscaped(&buf, allocator, data."); + try self.writeSanitizedFieldName(val); } else { try self.write("try buf.appendSlice(allocator, data."); + try self.writeSanitizedFieldName(val); } - // Sanitize field name - try self.writeSanitizedFieldName(val); try self.writeLine(");"); try self.writeIndent(); @@ -285,15 +350,43 @@ pub const Codegen = struct { // Output interpolated field const field_name = val[start..end]; try self.writeIndent(); + + // Check if this is a loop variable reference (e.g., item.name) + const is_loop_var = self.isLoopVariableReference(field_name); + // Check if the field has a non-string type hint + const needs_value_helper = is_loop_var or self.hasNonStringTypeHint(field_name); + if (text_node.buffer) { // Escaped (default) - try self.write("try helpers.appendEscaped(&buf, allocator, data."); + if (needs_value_helper) { + // Use appendValue for typed fields (handles any type) + try self.write("try helpers.appendValue(&buf, allocator, "); + if (is_loop_var) { + try self.writeFieldReference(field_name); + } else { + try self.write("data."); + try self.writeSanitizedFieldName(field_name); + } + } else { + try self.write("try helpers.appendEscaped(&buf, allocator, data."); + try self.writeSanitizedFieldName(field_name); + } } else { // Unescaped (unsafe) - try self.write("try buf.appendSlice(allocator, data."); + if (needs_value_helper) { + // Use appendValue for typed fields (handles any type) + try self.write("try helpers.appendValue(&buf, allocator, "); + if (is_loop_var) { + try self.writeFieldReference(field_name); + } else { + try self.write("data."); + try self.writeSanitizedFieldName(field_name); + } + } else { + try self.write("try buf.appendSlice(allocator, data."); + try self.writeSanitizedFieldName(field_name); + } } - // Sanitize field name (replace dots with underscores) - try self.writeSanitizedFieldName(field_name); try self.writeLine(");"); i = end + 1; @@ -318,14 +411,27 @@ pub const Codegen = struct { // Flush static buffer before dynamic content try self.flushStaticBuffer(); + // Check if this is a loop variable reference + const is_loop_var = self.isLoopVariableReference(val); + const needs_value_helper = is_loop_var or self.hasNonStringTypeHint(val); + try self.writeIndent(); - if (code_node.must_escape) { + if (needs_value_helper) { + // Use appendValue for typed fields (handles any type) + try self.write("try helpers.appendValue(&buf, allocator, "); + if (is_loop_var) { + try self.writeFieldReference(val); + } else { + try self.write("data."); + try self.writeSanitizedFieldName(val); + } + } else if (code_node.must_escape) { try self.write("try helpers.appendEscaped(&buf, allocator, data."); + try self.writeSanitizedFieldName(val); } else { try self.write("try buf.appendSlice(allocator, data."); + try self.writeSanitizedFieldName(val); } - // Sanitize field name - try self.writeSanitizedFieldName(val); try self.writeLine(");"); } // Unbuffered code is not supported in static compilation @@ -365,6 +471,40 @@ pub const Codegen = struct { } } + fn generateEach(self: *Codegen, node: *Node) !void { + const collection = node.obj orelse return; + const item_var = node.val orelse "item"; + + // Flush static content before loop + try self.flushStaticBuffer(); + + // Generate: for (data.collection) |item| { + try self.writeIndent(); + try self.write("for (data."); + try self.writeSanitizedFieldName(collection); + try self.write(") |"); + try self.write(item_var); + try self.writeLine("| {"); + self.indent_level += 1; + + // Track loop variable for field resolution + const prev_loop_var = self.current_loop_var; + self.current_loop_var = item_var; + + // Generate loop body + for (node.nodes.items) |child| { + try self.generateNode(child); + } + + // Flush any remaining static content + try self.flushStaticBuffer(); + + self.current_loop_var = prev_loop_var; + self.indent_level -= 1; + try self.writeIndent(); + try self.writeLine("}"); + } + fn generateConditional(self: *Codegen, cond: *Node) !void { // For compiled templates, generate Zig if/else statements // Only support simple field references like "isLoggedIn" or "count > 0" @@ -519,6 +659,113 @@ pub const Codegen = struct { } } + /// Check if field_name is a loop variable reference (e.g., "item" or "item.name" when current_loop_var is "item") + fn isLoopVariableReference(self: *Codegen, field_name: []const u8) bool { + const loop_var = self.current_loop_var orelse return false; + // Exact match (e.g., "item" when loop var is "item") + if (std.mem.eql(u8, field_name, loop_var)) return true; + // Field access (e.g., "item.name" when loop var is "item") + if (field_name.len > loop_var.len and + std.mem.startsWith(u8, field_name, loop_var) and + field_name[loop_var.len] == '.') + { + return true; + } + return false; + } + + /// Check if a field has a non-string type hint (requires helpers.appendValue) + fn hasNonStringTypeHint(self: *Codegen, field_name: []const u8) bool { + const type_info = self.type_hints.get(field_name) orelse return false; + + // Arrays always need appendValue + if (type_info.is_array) return true; + + // Structs need appendValue + if (type_info.struct_fields != null) return true; + + // Check primitive type + if (type_info.primitive_type) |prim| { + // String types don't need appendValue + if (std.mem.eql(u8, prim, "[]const u8")) return false; + // All other primitives (f32, i32, bool, etc.) need appendValue + return true; + } + + return false; + } + + /// Write a field reference, handling loop variables (item.name -> item.name) vs data fields (field -> data.field) + fn writeFieldReference(self: *Codegen, field_name: []const u8) !void { + // For loop variable references, write directly (e.g., "item.name") + try self.write(field_name); + } + + /// Write type information to output (generates Zig type syntax with default value) + fn writeTypeInfo(self: *Codegen, type_info: TypeInfo) !void { + if (type_info.is_array) { + // Array type: []const struct { ... } = &.{} + if (type_info.struct_fields) |struct_fields| { + try self.write("[]const struct { "); + var iter = struct_fields.iterator(); + var first = true; + while (iter.next()) |entry| { + if (!first) { + try self.write(", "); + } + first = false; + try self.write(entry.key_ptr.*); + try self.write(": "); + try self.write(entry.value_ptr.*); + } + try self.write(" } = &.{}"); + } else if (type_info.primitive_type) |prim| { + // Array of primitives: []const u8, []i32, etc. + try self.write("[]const "); + try self.write(prim); + try self.write(" = &.{}"); + } else { + // Fallback: array of strings + try self.write("[]const []const u8 = &.{}"); + } + } else { + // Non-array type + if (type_info.struct_fields) |struct_fields| { + // Anonymous struct + try self.write("struct { "); + var iter = struct_fields.iterator(); + var first = true; + while (iter.next()) |entry| { + if (!first) { + try self.write(", "); + } + first = false; + try self.write(entry.key_ptr.*); + try self.write(": "); + try self.write(entry.value_ptr.*); + } + try self.write(" } = .{}"); + } else if (type_info.primitive_type) |prim| { + // Primitive type with appropriate default + try self.write(prim); + if (std.mem.eql(u8, prim, "[]const u8")) { + try self.write(" = \"\""); + } else if (std.mem.eql(u8, prim, "bool")) { + try self.write(" = false"); + } else if (std.mem.startsWith(u8, prim, "i") or std.mem.startsWith(u8, prim, "u")) { + try self.write(" = 0"); + } else if (std.mem.startsWith(u8, prim, "f")) { + try self.write(" = 0.0"); + } else { + // Unknown type, no default + } + } else { + // Fallback: string + try self.write("[]const u8 = \"\""); + } + } + } + /// Check if value is a data field reference (simple identifier, may contain dots) fn isDataFieldReference(self: *Codegen, val: []const u8) bool { _ = self; @@ -550,7 +797,10 @@ pub fn extractFieldNames(allocator: Allocator, ast: *Node) ![][]const u8 { var fields = std.StringHashMap(void).init(allocator); defer fields.deinit(); - try extractFieldNamesRecursive(ast, &fields); + var loop_vars = std.StringHashMap(void).init(allocator); + defer loop_vars.deinit(); + + try extractFieldNamesRecursive(ast, &fields, &loop_vars); // Convert to sorted slice and sanitize field names var result: std.ArrayListUnmanaged([]const u8) = .{}; @@ -583,7 +833,38 @@ pub fn extractFieldNames(allocator: Allocator, ast: *Node) ![][]const u8 { return slice; } -fn extractFieldNamesRecursive(node: *Node, fields: *std.StringHashMap(void)) !void { +fn extractFieldNamesRecursive(node: *Node, fields: *std.StringHashMap(void), loop_vars: *std.StringHashMap(void)) !void { + // Handle TypeHint nodes - just add the field name, type info is handled separately + if (node.type == .TypeHint) { + if (node.type_hint_field) |field| { + try fields.put(field, {}); + } + return; + } + + // Handle Each/EachOf nodes - extract collection field and track loop variable + if (node.type == .Each or node.type == .EachOf) { + if (node.obj) |collection| { + const trimmed = std.mem.trim(u8, collection, " \t"); + if (trimmed.len > 0) { + try fields.put(trimmed, {}); + } + } + + // Track the loop variable (e.g., "item" in "each item in items") + const loop_var = node.val orelse "item"; + try loop_vars.put(loop_var, {}); + + // Recurse into loop body + for (node.nodes.items) |child| { + try extractFieldNamesRecursive(child, fields, loop_vars); + } + + // Remove loop variable after processing (for nested loops with same var name) + _ = loop_vars.remove(loop_var); + return; + } + // Extract from text interpolations: #{field} if (node.type == .Text or node.type == .Code) { if (node.val) |val| { @@ -597,7 +878,10 @@ fn extractFieldNamesRecursive(node: *Node, fields: *std.StringHashMap(void)) !vo if (end < val.len) { const field_name = val[start..end]; - try fields.put(field_name, {}); + // Skip if it's a loop variable reference + if (!isLoopVarReference(field_name, loop_vars)) { + try fields.put(field_name, {}); + } i = end + 1; continue; } @@ -607,7 +891,10 @@ fn extractFieldNamesRecursive(node: *Node, fields: *std.StringHashMap(void)) !vo // For Code nodes with buffer=true, the val itself is a field reference if (node.type == .Code and node.buffer) { - try fields.put(val, {}); + // Skip if it's a loop variable reference + if (!isLoopVarReference(val, loop_vars)) { + try fields.put(val, {}); + } } } } @@ -632,7 +919,8 @@ fn extractFieldNamesRecursive(node: *Node, fields: *std.StringHashMap(void)) !vo } } } - if (is_identifier) { + // Skip if it's a loop variable reference + if (is_identifier and !isLoopVarReference(val, loop_vars)) { try fields.put(val, {}); } } @@ -660,7 +948,8 @@ fn extractFieldNamesRecursive(node: *Node, fields: *std.StringHashMap(void)) !vo } } } - if (is_identifier) { + // Skip if it's a loop variable reference + if (is_identifier and !isLoopVarReference(field_name, loop_vars)) { try fields.put(field_name, {}); } } @@ -668,16 +957,114 @@ fn extractFieldNamesRecursive(node: *Node, fields: *std.StringHashMap(void)) !vo // Recurse into consequent and alternate if (node.consequent) |cons| { - try extractFieldNamesRecursive(cons, fields); + try extractFieldNamesRecursive(cons, fields, loop_vars); } if (node.alternate) |alt| { - try extractFieldNamesRecursive(alt, fields); + try extractFieldNamesRecursive(alt, fields, loop_vars); } } // Recurse into children for (node.nodes.items) |child| { - try extractFieldNamesRecursive(child, fields); + try extractFieldNamesRecursive(child, fields, loop_vars); + } +} + +/// Check if a field name is a loop variable reference (e.g., "item" or "item.name") +fn isLoopVarReference(field_name: []const u8, loop_vars: *std.StringHashMap(void)) bool { + // Check exact match (e.g., "item") + if (loop_vars.contains(field_name)) return true; + + // Check field access (e.g., "item.name" -> check "item") + if (std.mem.indexOf(u8, field_name, ".")) |dot_idx| { + const base = field_name[0..dot_idx]; + if (loop_vars.contains(base)) return true; + } + + return false; +} + +// ============================================================================ +// Type Hint Parsing +// ============================================================================ + +/// Parse a type spec string (e.g., "[]{name: []const u8, price: f32}") +pub fn parseTypeHintSpec(allocator: Allocator, spec: []const u8) !TypeInfo { + var info = TypeInfo{}; + var remaining = std.mem.trim(u8, spec, " \t"); + + // Check for array prefix [] + if (std.mem.startsWith(u8, remaining, "[]")) { + info.is_array = true; + remaining = remaining[2..]; + } + + // Check for struct definition {...} + if (remaining.len > 0 and remaining[0] == '{') { + info.struct_fields = try parseStructFields(allocator, remaining); + } else { + info.primitive_type = remaining; + } + + return info; +} + +/// Parse struct fields from "{field1: type1, field2: type2}" +fn parseStructFields(allocator: Allocator, spec: []const u8) !std.StringHashMap([]const u8) { + var fields = std.StringHashMap([]const u8).init(allocator); + errdefer fields.deinit(); + + // Remove braces + if (spec.len < 2) return fields; + const inner = spec[1 .. spec.len - 1]; + + // Split by comma and parse each field + var iter = std.mem.splitSequence(u8, inner, ","); + while (iter.next()) |field_spec| { + const trimmed = std.mem.trim(u8, field_spec, " \t"); + if (trimmed.len == 0) continue; + + // Find colon separator + if (std.mem.indexOf(u8, trimmed, ":")) |colon_idx| { + const field_name = std.mem.trim(u8, trimmed[0..colon_idx], " \t"); + const field_type = std.mem.trim(u8, trimmed[colon_idx + 1 ..], " \t"); + try fields.put(field_name, field_type); + } + } + + return fields; +} + +/// Collect type hints from an AST into a hash map +pub fn collectTypeHints(allocator: Allocator, ast: *Node, type_hints: *std.StringHashMap(TypeInfo)) !void { + try collectTypeHintsRecursive(allocator, ast, type_hints); +} + +fn collectTypeHintsRecursive(allocator: Allocator, node: *Node, type_hints: *std.StringHashMap(TypeInfo)) !void { + // Handle TypeHint nodes + if (node.type == .TypeHint) { + if (node.type_hint_field) |field| { + if (node.type_hint_type) |type_spec| { + const info = try parseTypeHintSpec(allocator, type_spec); + try type_hints.put(field, info); + } + } + return; + } + + // Recurse into conditional branches + if (node.type == .Conditional) { + if (node.consequent) |cons| { + try collectTypeHintsRecursive(allocator, cons, type_hints); + } + if (node.alternate) |alt| { + try collectTypeHintsRecursive(allocator, alt, type_hints); + } + } + + // Recurse into children + for (node.nodes.items) |child| { + try collectTypeHintsRecursive(allocator, child, type_hints); } } diff --git a/src/walk.zig b/src/walk.zig index ed0cdff..f1336c6 100644 --- a/src/walk.zig +++ b/src/walk.zig @@ -351,6 +351,7 @@ fn visitChildren( .MixinBlock, .YieldBlock, .Text, + .TypeHint, => {}, } }