fix: merge multiple class attributes in parser (single fix location)
- parser.zig: collect and merge all class values (shorthand and parenthesized) into single attribute - Filter out empty, null, undefined class values during parsing - Reverted redundant merging logic from codegen.zig, template.zig, zig_codegen.zig - Added documentation about shared AST consumers relationship
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
.{
|
.{
|
||||||
.name = .pugz,
|
.name = .pugz,
|
||||||
.version = "0.3.5",
|
.version = "0.3.6",
|
||||||
.fingerprint = 0x822db0790e17621d, // Changing this has security and trust implications.
|
.fingerprint = 0x822db0790e17621d, // Changing this has security and trust implications.
|
||||||
.minimum_zig_version = "0.15.2",
|
.minimum_zig_version = "0.15.2",
|
||||||
.dependencies = .{},
|
.dependencies = .{},
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ Pugz is a Pug-like HTML template engine written in Zig 0.15.2. It compiles Pug t
|
|||||||
- When the user specifies a new rule, update this CLAUDE.md file to include it.
|
- When the user specifies a new rule, update this CLAUDE.md file to include it.
|
||||||
- Code comments are required but must be meaningful, not bloated. Focus on explaining "why" not "what". Avoid obvious comments like "// increment counter" - instead explain complex logic, non-obvious decisions, or tricky edge cases.
|
- Code comments are required but must be meaningful, not bloated. Focus on explaining "why" not "what". Avoid obvious comments like "// increment counter" - instead explain complex logic, non-obvious decisions, or tricky edge cases.
|
||||||
- **All documentation files (.md) must be saved to the `docs/` directory.** Do not create .md files in the root directory or examples directories - always place them in `docs/`.
|
- **All documentation files (.md) must be saved to the `docs/` directory.** Do not create .md files in the root directory or examples directories - always place them in `docs/`.
|
||||||
- **Publish command**: When user says "publish", do the following:
|
- **Publish command**: Only when user explicitly says "publish", do the following:
|
||||||
1. Bump the fix version (patch version in build.zig.zon)
|
1. Bump the fix version (patch version in build.zig.zon)
|
||||||
2. Git commit with appropriate message
|
2. Git commit with appropriate message
|
||||||
3. Git push to remote `origin` and remote `github`
|
3. Git push to remote `origin` and remote `github`
|
||||||
|
- Do NOT publish automatically or without explicit user request.
|
||||||
|
|
||||||
## Build Commands
|
## Build Commands
|
||||||
|
|
||||||
@@ -35,9 +36,13 @@ Source → Lexer → Tokens → StripComments → Parser → AST → Linker →
|
|||||||
|
|
||||||
### Three Rendering Modes
|
### Three Rendering Modes
|
||||||
|
|
||||||
1. **Static compilation** (`pug.compile`): Outputs HTML directly
|
1. **Static compilation** (`pug.compile`): Outputs HTML directly via `codegen.zig`
|
||||||
2. **Data binding** (`template.renderWithData`): Supports `#{field}` interpolation with Zig structs
|
2. **Data binding** (`template.renderWithData`): Supports `#{field}` interpolation with Zig structs via `template.zig`
|
||||||
3. **Compiled templates** (`.pug` → `.zig`): Pre-compile templates to Zig functions for maximum performance
|
3. **Compiled templates** (`.pug` → `.zig`): Pre-compile templates to Zig functions via `zig_codegen.zig`
|
||||||
|
|
||||||
|
### Important: Shared AST Consumers
|
||||||
|
|
||||||
|
**codegen.zig**, **template.zig**, and **zig_codegen.zig** all consume the AST from the parser. When fixing bugs related to AST structure (like attribute handling, class merging, etc.), prefer fixing in **parser.zig** so all three rendering paths benefit from the fix automatically. Only fix in the individual codegen modules if the behavior should differ between rendering modes.
|
||||||
|
|
||||||
### Core Modules
|
### Core Modules
|
||||||
|
|
||||||
|
|||||||
@@ -354,31 +354,19 @@ pub const Compiler = struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn visitAttributes(self: *Compiler, tag: *Node) CompilerError!void {
|
fn visitAttributes(self: *Compiler, tag: *Node) CompilerError!void {
|
||||||
// Collect class values to merge them into a single attribute
|
|
||||||
var class_values = std.ArrayListUnmanaged([]const u8){};
|
|
||||||
defer class_values.deinit(self.allocator);
|
|
||||||
|
|
||||||
// First pass: collect class values and output non-class attributes
|
|
||||||
for (tag.attrs.items) |attr| {
|
for (tag.attrs.items) |attr| {
|
||||||
if (attr.val) |val| {
|
if (attr.val) |val| {
|
||||||
// Check if value should be skipped (empty, null, undefined)
|
// Skip empty class/style attributes
|
||||||
const should_skip = val.len == 0 or
|
if (mem.eql(u8, attr.name, "class") or mem.eql(u8, attr.name, "style")) {
|
||||||
|
// Skip if value is empty, null, or undefined
|
||||||
|
if (val.len == 0 or
|
||||||
mem.eql(u8, val, "''") or
|
mem.eql(u8, val, "''") or
|
||||||
mem.eql(u8, val, "\"\"") or
|
mem.eql(u8, val, "\"\"") or
|
||||||
mem.eql(u8, val, "null") or
|
mem.eql(u8, val, "null") or
|
||||||
mem.eql(u8, val, "undefined");
|
mem.eql(u8, val, "undefined"))
|
||||||
|
{
|
||||||
if (mem.eql(u8, attr.name, "class")) {
|
|
||||||
// Collect class values to merge later
|
|
||||||
if (!should_skip) {
|
|
||||||
try class_values.append(self.allocator, val);
|
|
||||||
}
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip empty style attributes
|
|
||||||
if (mem.eql(u8, attr.name, "style") and should_skip) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for boolean attributes in terse mode
|
// Check for boolean attributes in terse mode
|
||||||
@@ -410,19 +398,6 @@ pub const Compiler = struct {
|
|||||||
try self.write(attr.name);
|
try self.write(attr.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Output merged class attribute if any classes were collected
|
|
||||||
if (class_values.items.len > 0) {
|
|
||||||
try self.writeChar(' ');
|
|
||||||
try self.write("class=\"");
|
|
||||||
for (class_values.items, 0..) |class_val, i| {
|
|
||||||
if (i > 0) {
|
|
||||||
try self.writeChar(' ');
|
|
||||||
}
|
|
||||||
try self.write(class_val);
|
|
||||||
}
|
|
||||||
try self.writeChar('"');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn visitText(self: *Compiler, text: *Node) CompilerError!void {
|
fn visitText(self: *Compiler, text: *Node) CompilerError!void {
|
||||||
|
|||||||
@@ -1486,6 +1486,12 @@ pub const Parser = struct {
|
|||||||
var attribute_names = std.ArrayListUnmanaged([]const u8){};
|
var attribute_names = std.ArrayListUnmanaged([]const u8){};
|
||||||
defer attribute_names.deinit(self.allocator);
|
defer attribute_names.deinit(self.allocator);
|
||||||
|
|
||||||
|
// Collect class values to merge into single attribute
|
||||||
|
var class_values = std.ArrayListUnmanaged([]const u8){};
|
||||||
|
defer class_values.deinit(self.allocator);
|
||||||
|
var class_line: usize = 0;
|
||||||
|
var class_column: usize = 0;
|
||||||
|
|
||||||
// (attrs | class | id)*
|
// (attrs | class | id)*
|
||||||
outer: while (true) {
|
outer: while (true) {
|
||||||
switch (self.peek().type) {
|
switch (self.peek().type) {
|
||||||
@@ -1500,21 +1506,30 @@ pub const Parser = struct {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
try attribute_names.append(self.allocator, "id");
|
try attribute_names.append(self.allocator, "id");
|
||||||
}
|
// ID: add directly to attrs
|
||||||
// Class/id values from shorthand are always static strings
|
|
||||||
const val_str = tok.val.getString() orelse "";
|
const val_str = tok.val.getString() orelse "";
|
||||||
const final_val = try self.allocator.dupe(u8, val_str);
|
const final_val = try self.allocator.dupe(u8, val_str);
|
||||||
|
|
||||||
try tag.attrs.append(self.allocator, .{
|
try tag.attrs.append(self.allocator, .{
|
||||||
.name = if (tok.type == .id) "id" else "class",
|
.name = "id",
|
||||||
.val = final_val,
|
.val = final_val,
|
||||||
.line = tok.loc.start.line,
|
.line = tok.loc.start.line,
|
||||||
.column = tok.loc.start.column,
|
.column = tok.loc.start.column,
|
||||||
.filename = self.filename,
|
.filename = self.filename,
|
||||||
.must_escape = false,
|
.must_escape = false,
|
||||||
.val_owned = true, // We allocated this string
|
.val_owned = true,
|
||||||
.quoted = true, // Shorthand class/id are always static
|
.quoted = true,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// Class: collect for merging later
|
||||||
|
const val_str = tok.val.getString() orelse "";
|
||||||
|
if (val_str.len > 0) {
|
||||||
|
try class_values.append(self.allocator, val_str);
|
||||||
|
if (class_line == 0) {
|
||||||
|
class_line = tok.loc.start.line;
|
||||||
|
class_column = tok.loc.start.column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
.start_attributes => {
|
.start_attributes => {
|
||||||
if (seen_attrs) {
|
if (seen_attrs) {
|
||||||
@@ -1523,8 +1538,33 @@ pub const Parser = struct {
|
|||||||
seen_attrs = true;
|
seen_attrs = true;
|
||||||
var new_attrs = try self.attrs(&attribute_names);
|
var new_attrs = try self.attrs(&attribute_names);
|
||||||
for (new_attrs.items) |attr| {
|
for (new_attrs.items) |attr| {
|
||||||
|
// Collect class attributes for merging
|
||||||
|
if (mem.eql(u8, attr.name, "class")) {
|
||||||
|
if (attr.val) |val| {
|
||||||
|
// Skip empty, null, undefined values
|
||||||
|
const should_skip = val.len == 0 or
|
||||||
|
mem.eql(u8, val, "null") or
|
||||||
|
mem.eql(u8, val, "undefined") or
|
||||||
|
mem.eql(u8, val, "''") or
|
||||||
|
mem.eql(u8, val, "\"\"");
|
||||||
|
if (!should_skip) {
|
||||||
|
try class_values.append(self.allocator, val);
|
||||||
|
if (class_line == 0) {
|
||||||
|
class_line = attr.line;
|
||||||
|
class_column = attr.column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Free owned value since we're collecting it
|
||||||
|
if (attr.val_owned) {
|
||||||
|
if (attr.val) |val| {
|
||||||
|
self.allocator.free(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
try tag.attrs.append(self.allocator, attr);
|
try tag.attrs.append(self.allocator, attr);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
new_attrs.deinit(self.allocator);
|
new_attrs.deinit(self.allocator);
|
||||||
},
|
},
|
||||||
.@"&attributes" => {
|
.@"&attributes" => {
|
||||||
@@ -1540,6 +1580,39 @@ pub const Parser = struct {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create single merged class attribute if any classes were collected
|
||||||
|
if (class_values.items.len > 0) {
|
||||||
|
// Calculate total length needed
|
||||||
|
var total_len: usize = 0;
|
||||||
|
for (class_values.items, 0..) |val, i| {
|
||||||
|
total_len += val.len;
|
||||||
|
if (i > 0) total_len += 1; // space separator
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build merged class string
|
||||||
|
const merged = try self.allocator.alloc(u8, total_len);
|
||||||
|
var pos: usize = 0;
|
||||||
|
for (class_values.items, 0..) |val, i| {
|
||||||
|
if (i > 0) {
|
||||||
|
merged[pos] = ' ';
|
||||||
|
pos += 1;
|
||||||
|
}
|
||||||
|
@memcpy(merged[pos .. pos + val.len], val);
|
||||||
|
pos += val.len;
|
||||||
|
}
|
||||||
|
|
||||||
|
try tag.attrs.append(self.allocator, .{
|
||||||
|
.name = "class",
|
||||||
|
.val = merged,
|
||||||
|
.line = class_line,
|
||||||
|
.column = class_column,
|
||||||
|
.filename = self.filename,
|
||||||
|
.must_escape = false,
|
||||||
|
.val_owned = true,
|
||||||
|
.quoted = true, // Merged classes are static
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Check for textOnly (.)
|
// Check for textOnly (.)
|
||||||
if (self.peek().type == .dot) {
|
if (self.peek().type == .dot) {
|
||||||
tag.text_only = true;
|
tag.text_only = true;
|
||||||
|
|||||||
@@ -277,10 +277,6 @@ fn renderTag(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), tag: *No
|
|||||||
try output.appendSlice(allocator, "<");
|
try output.appendSlice(allocator, "<");
|
||||||
try output.appendSlice(allocator, name);
|
try output.appendSlice(allocator, name);
|
||||||
|
|
||||||
// Collect class values separately to merge them into one attribute
|
|
||||||
var class_parts = std.ArrayListUnmanaged([]const u8){};
|
|
||||||
defer class_parts.deinit(allocator);
|
|
||||||
|
|
||||||
// Render attributes directly to output buffer (avoids intermediate allocations)
|
// Render attributes directly to output buffer (avoids intermediate allocations)
|
||||||
for (tag.attrs.items) |attr| {
|
for (tag.attrs.items) |attr| {
|
||||||
// Substitute mixin arguments in attribute value if we're inside a mixin
|
// Substitute mixin arguments in attribute value if we're inside a mixin
|
||||||
@@ -294,26 +290,8 @@ fn renderTag(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), tag: *No
|
|||||||
else
|
else
|
||||||
try evaluateAttrValue(allocator, final_val, data);
|
try evaluateAttrValue(allocator, final_val, data);
|
||||||
|
|
||||||
// Collect class attributes for merging
|
|
||||||
if (std.mem.eql(u8, attr.name, "class")) {
|
|
||||||
switch (attr_val) {
|
|
||||||
.string => |s| if (s.len > 0) try class_parts.append(allocator, s),
|
|
||||||
else => {},
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
try runtime.appendAttr(allocator, output, attr.name, attr_val, true, ctx.terse);
|
try runtime.appendAttr(allocator, output, attr.name, attr_val, true, ctx.terse);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Output merged class attribute
|
|
||||||
if (class_parts.items.len > 0) {
|
|
||||||
try output.appendSlice(allocator, " class=\"");
|
|
||||||
for (class_parts.items, 0..) |part, i| {
|
|
||||||
if (i > 0) try output.append(allocator, ' ');
|
|
||||||
try output.appendSlice(allocator, part);
|
|
||||||
}
|
|
||||||
try output.append(allocator, '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Self-closing logic differs by mode:
|
// Self-closing logic differs by mode:
|
||||||
// - HTML5 terse: void elements are self-closing without />
|
// - HTML5 terse: void elements are self-closing without />
|
||||||
@@ -683,10 +661,6 @@ fn renderTagWithItem(allocator: Allocator, output: *std.ArrayListUnmanaged(u8),
|
|||||||
try output.appendSlice(allocator, "<");
|
try output.appendSlice(allocator, "<");
|
||||||
try output.appendSlice(allocator, name);
|
try output.appendSlice(allocator, name);
|
||||||
|
|
||||||
// Collect class values separately to merge them into one attribute
|
|
||||||
var class_parts = std.ArrayListUnmanaged([]const u8){};
|
|
||||||
defer class_parts.deinit(allocator);
|
|
||||||
|
|
||||||
// Render attributes directly to output buffer (avoids intermediate allocations)
|
// Render attributes directly to output buffer (avoids intermediate allocations)
|
||||||
for (tag.attrs.items) |attr| {
|
for (tag.attrs.items) |attr| {
|
||||||
// Static/quoted values (e.g., from .class shorthand) should not be looked up in data
|
// Static/quoted values (e.g., from .class shorthand) should not be looked up in data
|
||||||
@@ -695,26 +669,8 @@ fn renderTagWithItem(allocator: Allocator, output: *std.ArrayListUnmanaged(u8),
|
|||||||
else
|
else
|
||||||
try evaluateAttrValue(allocator, attr.val, data);
|
try evaluateAttrValue(allocator, attr.val, data);
|
||||||
|
|
||||||
// Collect class attributes for merging
|
|
||||||
if (std.mem.eql(u8, attr.name, "class")) {
|
|
||||||
switch (attr_val) {
|
|
||||||
.string => |s| if (s.len > 0) try class_parts.append(allocator, s),
|
|
||||||
else => {},
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
try runtime.appendAttr(allocator, output, attr.name, attr_val, true, ctx.terse);
|
try runtime.appendAttr(allocator, output, attr.name, attr_val, true, ctx.terse);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Output merged class attribute
|
|
||||||
if (class_parts.items.len > 0) {
|
|
||||||
try output.appendSlice(allocator, " class=\"");
|
|
||||||
for (class_parts.items, 0..) |part, i| {
|
|
||||||
if (i > 0) try output.append(allocator, ' ');
|
|
||||||
try output.appendSlice(allocator, part);
|
|
||||||
}
|
|
||||||
try output.append(allocator, '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
const is_void = isSelfClosing(name);
|
const is_void = isSelfClosing(name);
|
||||||
const is_self_closing = if (ctx.terse)
|
const is_self_closing = if (ctx.terse)
|
||||||
|
|||||||
@@ -224,19 +224,7 @@ pub const Codegen = struct {
|
|||||||
|
|
||||||
if (!has_dynamic_attrs) {
|
if (!has_dynamic_attrs) {
|
||||||
// All static attributes - include in buffer
|
// All static attributes - include in buffer
|
||||||
// Collect class values to merge into single attribute
|
|
||||||
var class_values = std.ArrayListUnmanaged([]const u8){};
|
|
||||||
defer class_values.deinit(self.allocator);
|
|
||||||
|
|
||||||
for (tag.attrs.items) |attr| {
|
for (tag.attrs.items) |attr| {
|
||||||
if (std.mem.eql(u8, attr.name, "class")) {
|
|
||||||
// Collect class values for merging
|
|
||||||
if (attr.val) |val| {
|
|
||||||
if (val.len > 0) {
|
|
||||||
try class_values.append(self.allocator, val);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
try self.addStatic(" ");
|
try self.addStatic(" ");
|
||||||
try self.addStatic(attr.name);
|
try self.addStatic(attr.name);
|
||||||
if (attr.val) |val| {
|
if (attr.val) |val| {
|
||||||
@@ -252,41 +240,12 @@ pub const Codegen = struct {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Output merged class attribute
|
|
||||||
if (class_values.items.len > 0) {
|
|
||||||
try self.addStatic(" class=\"");
|
|
||||||
for (class_values.items, 0..) |class_val, i| {
|
|
||||||
if (i > 0) {
|
|
||||||
try self.addStatic(" ");
|
|
||||||
}
|
|
||||||
try self.addStatic(class_val);
|
|
||||||
}
|
|
||||||
try self.addStatic("\"");
|
|
||||||
}
|
|
||||||
|
|
||||||
try self.addStatic(">");
|
try self.addStatic(">");
|
||||||
} else {
|
} else {
|
||||||
// Flush static content before dynamic attributes (this closes any open string)
|
// Flush static content before dynamic attributes (this closes any open string)
|
||||||
try self.flushStaticBuffer();
|
try self.flushStaticBuffer();
|
||||||
|
|
||||||
// Collect static class values for merging
|
|
||||||
var static_class_values = std.ArrayListUnmanaged([]const u8){};
|
|
||||||
defer static_class_values.deinit(self.allocator);
|
|
||||||
|
|
||||||
// First pass: output non-class attributes
|
|
||||||
for (tag.attrs.items) |attr| {
|
for (tag.attrs.items) |attr| {
|
||||||
// Skip class attributes - handle them separately
|
|
||||||
if (std.mem.eql(u8, attr.name, "class")) {
|
|
||||||
if (attr.val) |val| {
|
|
||||||
if (val.len > 0) {
|
|
||||||
try static_class_values.append(self.allocator, val);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attr.val) |val| {
|
if (attr.val) |val| {
|
||||||
// Quoted values are always static, unquoted can be field references
|
// Quoted values are always static, unquoted can be field references
|
||||||
if (!attr.quoted and self.isDataFieldReference(val)) {
|
if (!attr.quoted and self.isDataFieldReference(val)) {
|
||||||
@@ -344,19 +303,6 @@ pub const Codegen = struct {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Output merged class attribute
|
|
||||||
if (static_class_values.items.len > 0) {
|
|
||||||
try self.writeIndent();
|
|
||||||
try self.write("try buf.appendSlice(allocator, \" class=\\\"");
|
|
||||||
for (static_class_values.items, 0..) |class_val, i| {
|
|
||||||
if (i > 0) {
|
|
||||||
try self.write(" ");
|
|
||||||
}
|
|
||||||
try self.writeEscaped(class_val);
|
|
||||||
}
|
|
||||||
try self.writeLine("\\\"\");");
|
|
||||||
}
|
|
||||||
|
|
||||||
try self.writeIndent();
|
try self.writeIndent();
|
||||||
try self.writeLine("try buf.appendSlice(allocator, \">\");");
|
try self.writeLine("try buf.appendSlice(allocator, \">\");");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user