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:
2026-01-29 22:16:55 +05:30
parent c3156f88bd
commit c7d53e56a9
6 changed files with 119 additions and 164 deletions

View File

@@ -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 = .{},

View File

@@ -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

View File

@@ -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")) {
mem.eql(u8, val, "''") or // Skip if value is empty, null, or undefined
mem.eql(u8, val, "\"\"") or if (val.len == 0 or
mem.eql(u8, val, "null") or mem.eql(u8, val, "''") or
mem.eql(u8, val, "undefined"); mem.eql(u8, val, "\"\"") or
mem.eql(u8, val, "null") or
if (mem.eql(u8, attr.name, "class")) { mem.eql(u8, val, "undefined"))
// Collect class values to merge later {
if (!should_skip) { continue;
try class_values.append(self.allocator, val);
} }
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 {

View File

@@ -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
const val_str = tok.val.getString() orelse "";
const final_val = try self.allocator.dupe(u8, val_str);
try tag.attrs.append(self.allocator, .{
.name = "id",
.val = final_val,
.line = tok.loc.start.line,
.column = tok.loc.start.column,
.filename = self.filename,
.must_escape = false,
.val_owned = true,
.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;
}
}
} }
// Class/id values from shorthand are always static strings
const val_str = tok.val.getString() orelse "";
const final_val = try self.allocator.dupe(u8, val_str);
try tag.attrs.append(self.allocator, .{
.name = if (tok.type == .id) "id" else "class",
.val = final_val,
.line = tok.loc.start.line,
.column = tok.loc.start.column,
.filename = self.filename,
.must_escape = false,
.val_owned = true, // We allocated this string
.quoted = true, // Shorthand class/id are always static
});
}, },
.start_attributes => { .start_attributes => {
if (seen_attrs) { if (seen_attrs) {
@@ -1523,7 +1538,32 @@ 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| {
try tag.attrs.append(self.allocator, 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);
}
} }
new_attrs.deinit(self.allocator); new_attrs.deinit(self.allocator);
}, },
@@ -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;

View File

@@ -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,25 +290,7 @@ 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 try runtime.appendAttr(allocator, output, attr.name, attr_val, true, ctx.terse);
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);
}
}
// 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:
@@ -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,25 +669,7 @@ 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 try runtime.appendAttr(allocator, output, attr.name, attr_val, true, ctx.terse);
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);
}
}
// 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);

View File

@@ -224,69 +224,28 @@ 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")) { try self.addStatic(" ");
// Collect class values for merging try self.addStatic(attr.name);
if (attr.val) |val| { if (attr.val) |val| {
if (val.len > 0) { try self.addStatic("=\"");
try class_values.append(self.allocator, val); try self.addStatic(val);
} try self.addStatic("\"");
}
} else { } else {
try self.addStatic(" "); // Boolean attribute
try self.addStatic(attr.name); if (!self.terse) {
if (attr.val) |val| {
try self.addStatic("=\""); try self.addStatic("=\"");
try self.addStatic(val); try self.addStatic(attr.name);
try self.addStatic("\""); try self.addStatic("\"");
} else {
// Boolean attribute
if (!self.terse) {
try self.addStatic("=\"");
try self.addStatic(attr.name);
try self.addStatic("\"");
}
} }
} }
} }
// 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, \">\");");
} }