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,
.version = "0.3.5",
.version = "0.3.6",
.fingerprint = 0x822db0790e17621d, // Changing this has security and trust implications.
.minimum_zig_version = "0.15.2",
.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.
- 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/`.
- **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)
2. Git commit with appropriate message
3. Git push to remote `origin` and remote `github`
- Do NOT publish automatically or without explicit user request.
## Build Commands
@@ -35,9 +36,13 @@ Source → Lexer → Tokens → StripComments → Parser → AST → Linker →
### Three Rendering Modes
1. **Static compilation** (`pug.compile`): Outputs HTML directly
2. **Data binding** (`template.renderWithData`): Supports `#{field}` interpolation with Zig structs
3. **Compiled templates** (`.pug``.zig`): Pre-compile templates to Zig functions for maximum performance
1. **Static compilation** (`pug.compile`): Outputs HTML directly via `codegen.zig`
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 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

View File

@@ -354,31 +354,19 @@ pub const Compiler = struct {
}
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| {
if (attr.val) |val| {
// Check if value should be skipped (empty, null, undefined)
const should_skip = val.len == 0 or
// Skip empty class/style attributes
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, "null") or
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);
}
mem.eql(u8, val, "undefined"))
{
continue;
}
// Skip empty style attributes
if (mem.eql(u8, attr.name, "style") and should_skip) {
continue;
}
// Check for boolean attributes in terse mode
@@ -410,19 +398,6 @@ pub const Compiler = struct {
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 {

View File

@@ -1486,6 +1486,12 @@ pub const Parser = struct {
var attribute_names = std.ArrayListUnmanaged([]const u8){};
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)*
outer: while (true) {
switch (self.peek().type) {
@@ -1500,21 +1506,30 @@ pub const Parser = struct {
}
}
try attribute_names.append(self.allocator, "id");
}
// Class/id values from shorthand are always static strings
// 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 = if (tok.type == .id) "id" else "class",
.name = "id",
.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
.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;
}
}
}
},
.start_attributes => {
if (seen_attrs) {
@@ -1523,8 +1538,33 @@ pub const Parser = struct {
seen_attrs = true;
var new_attrs = try self.attrs(&attribute_names);
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);
}
}
new_attrs.deinit(self.allocator);
},
.@"&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 (.)
if (self.peek().type == .dot) {
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, 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)
for (tag.attrs.items) |attr| {
// 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
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);
}
}
// 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:
// - 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, 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)
for (tag.attrs.items) |attr| {
// 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
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);
}
}
// 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_self_closing = if (ctx.terse)

View File

@@ -224,19 +224,7 @@ pub const Codegen = struct {
if (!has_dynamic_attrs) {
// 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| {
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(attr.name);
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(">");
} else {
// Flush static content before dynamic attributes (this closes any open string)
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| {
// 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| {
// Quoted values are always static, unquoted can be field references
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.writeLine("try buf.appendSlice(allocator, \">\");");
}